cursedgate
v0.7.0
Published
The fleet's shippability gate — one definition of what must be true before an app is done, enforced identically before landing and before deploying. Node-only, zero dependencies, never bundled.
Readme
gatekit
The fleet's shippability gate. One definition of what must be true before an app is done, evaluated the same way before landing and before deploying.
Node-only, zero runtime dependencies, ships source. Never bundled into an app.
The problem it exists for
For months, agents finishing a task reported it green, landed it, and someone else discovered days later — always mid-deploy — that a gate had been red the whole time. The recurring sentence was some variant of:
couldn't be built or deployed at all on main before today — a stylesheet gate failing on two components, pre-existing and unrelated (verified by building at the prior commit).
Three mechanisms produced that, and none of them was a missing check.
1. The pre-land gate was not a superset of the deploy gate. In
cursed-satellites, bun run verify is typecheck && lint && test &&
test:gates && vendor:diff. It never builds. The deploy engine's plan has a
build step, and buildSatelliteArtifact asserts belt-CSS coverage right after
Vite writes the module manifest. So the assertion's first real evaluation was
inside someone's deploy, days after the commit that broke it.
2. A skipped check reports as a pass. The repo-level stylesheet gate reads
belt-modules.ignore.json — a gitignored build artifact. A fresh worktree, the
environment agents are required to work in, has none. The gate printed
(skipped admin: no belt-modules.ignore.json — run its build first) and the
suite reported 14 pass / 0 fail. Agents read exit codes, not logs.
3. Hand-maintained rosters drifted. Measured on 2026-08-04:
scripts/assetBudget.test.ts carried budgets for 5 apps out of 12 on disk, with
no completeness assertion anywhere in the file. Seven live apps had no byte
budget, and nothing in the output distinguished "within budget" from "never
given one".
All three are the same bug: a check that was not evaluated read as a check that passed.
The fix, in one line
Checks do not return booleans. They return a Verdict, whose third state is
unevaluated, and unevaluated is a red gate.
summarize([unevaluated("belt-css", "notes", "no manifest", "run its build")]).ok
// => falseEverything else in this package is plumbing to make that third state cheap to produce honestly.
What you run
gate preflight # rehearse the apps this branch can affect
gate preflight --all # every app (batch finalize)
gate preflight --app notes # one app
gate affected # what preflight would select, and why
gate convention # static checks only — no build, no bootExit code is 0 only when every check passed.
preflight — the local prod rehearsal
The deploy engine's plan has fourteen steps. Exactly three can fail because of something in a diff rather than something about the host:
| step | what it catches |
|-------|-----------------|
| build | the stylesheet gate, bundle budgets, anything buildSatelliteArtifact asserts |
| flip | an artifact that will not boot |
| smoke | a route, a health payload, an auth gate |
preflight runs those three against the real production artifact, on a free
loopback port, and throws it away. No host is contacted and nothing is published.
The failure the fleet kept discovering mid-deploy surfaces on the branch that
caused it.
It is generic because the fleet is already uniform: every deployable satellite
declares build:deploy → dist-deploy → server.js → smoke:deploy and a
/healthz in the cursed manifest it already had.
The artifact runs exactly the way preview:prod runs it — PORT and nothing
else. An earlier draft also set NODE_ENV=production so the rehearsal would be
"more production-like". The first real app tried died at boot with
ADMIN_SESSION_SECRET is required in production. The app was right; the gate was
inventing a runtime configuration nothing ships, and the only cure would have
been teaching a per-task check to load real production secrets.
affected — why narrowing is safe
Building and rehearsing twelve satellites costs ~49s of client build plus a boot-and-smoke each. Affordable at batch-finalize, irritating on a one-line fix, and an irritating gate is a gate someone disables. So the per-task gate narrows.
Narrowing is the dangerous half, so the bias is one-directional and absolute:
A path the detector does not recognize selects EVERY app.
Not "no apps", not "warn". A new top-level directory or an unmodeled config file means the detector's picture of the repo is stale, and the only safe response to a stale picture is to stop narrowing. Over-selecting costs 49 seconds; under-selecting costs a fortnight and a production incident.
The single carve-out is documentation, and only when every changed path is
markdown outside an app — one .ts among a hundred .md files disqualifies the
whole diff. Markdown inside an app still selects that app, because
apps/hobbies/___WIP-CLAIM.md is read by a live gate in this fleet.
Waivers
Some checks genuinely cannot apply — gateway serves no page, so it has no
stylesheet to cover. A gate that cries wolf gets a --skip flag bolted on within
a week and is decorative thereafter. So an unevaluated may be waived, but only
by naming the check and the target with a reason, in gatekit.waivers.json at
the repo root:
[
{
"check": "artifact-smoke",
"target": "orch",
"because": "its smoke does the real fleet SSO dance and auth registers consumers BY ORIGIN, so a rehearsal's ephemeral port can never be pre-registered"
}
]A file rather than a flag, deliberately: a --skip on a command line is
invisible in review, gets copied into the next runbook, and outlives its reason
by months. A malformed waivers file is a FAILURE rather than "no waivers" —
a typo there would silently re-open every hole it covered.
Two things a waiver deliberately cannot do:
- Silence a
fail. A check that ran and said no is fixed, or the check is deleted along with its reason. There is no mute button. - Outlive its reason. A waiver matching no verdict is itself reported as a failure. Otherwise the next genuine gap at that coordinate arrives pre-forgiven.
How to find the next one of these
The bug class this package exists for has a mechanical tell, and it is worth knowing independently of any code here:
Run the same gate, at the same commit, in a fresh worktree and in the primary checkout. Any check that disagrees is reading state that is not in git.
Three were found in cursed-satellites in one afternoon without going looking:
| check | the state it reads |
|---|---|
| belt-CSS coverage | belt-modules.ignore.json — a gitignored build manifest |
| asset budgets | dist-deploy/ — present only if someone built |
| patterns' /samples smoke | samples.ignore/ — gitignored encrypted blobs |
The third is the clearest demonstration, because it fails the right way in the primary and passes vacuously in a worktree: with blobs present it correctly reports them stale; with the directory absent it takes its "no blobs yet" branch and goes green. Same commit, same command, opposite verdict, decided entirely by untracked state.
Roster coverage
A hand-written list is only ever an input to a completeness check, never the definition of the fleet. The definition is what is on disk.
checkRosterCoverage({ check: "asset-budget", roster: BUDGETS.map(b => b.app), expected: discoverApps({ repoRoot }) })Red in three ways, and the third has actually bitten: an app on disk the roster never got; a roster entry naming an app that no longer exists (its cases have been silently skipping ever since); an exemption for an app that is gone.
Layout
| file | what it holds |
|---|---|
| verdict.ts | the protocol — start here |
| manifest.ts | reads the cursed key that already exists in every package.json |
| discover.ts | filesystem-derived roster + coverage check |
| affected.ts | dependency-aware change detection |
| rehearse.ts | build → boot → smoke, with guaranteed teardown |
| host.ts | the only module touching processes, sockets or the clock |
| bin/gate.ts | the CLI |
Host is injected, so the rehearsal is fully tested without building or booting
anything.
Consuming it
// package.json
"devDependencies": {
"cursedgate": "^0.6.0"
}🔴 By published version, never by path. This block used to show a file: spec
pointing into a directory that no longer exists — which is both a dead path and
the exact shape src/noPathDeps.test.ts refuses one file away: file: and
link: defeat --frozen-lockfile, and a file: install is served from a cache
keyed on the version string, so re-packing the same version ships stale bytes
straight through --force.
It ships source and is consumed through the bun condition, so there is no
dist to go stale — and no exposure to the hardlink-staleness trap documented in
the root CLAUDE.md, where a file: dep's installed copy keeps serving orphaned
content after a git checkout. A gate that can itself be silently out of date
would be a poor gate.
