diff --git a/.githooks/pre-push b/.githooks/pre-push index 8aa31a67d..6ba4a4cba 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,5 +1,6 @@ # Agent-only ship gate (humans: no-op). See scripts/agent-pre-push.mjs. -# Draft / no PR: free push. Ready PR: check + lint + test. +# Every push (draft included): check + lint + test. No browser/API suite exists +# here, so there is nothing a draft could usefully defer. # Publish: pnpm pr:ready, or raw `gh pr ready` — the shim runs the same gate first. # # Humans: SKIP_AGENT_PREPUSH=1 git push diff --git a/AGENTS.md b/AGENTS.md index 61bcf99c5..0ca05556b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This is the Effect App library repository, focusing on functional programming pa Always open a PR for agent work that changes the repo — do not leave finished work only on a local branch. -- **Draft early**: open a draft PR as soon as there is a meaningful commit (or when starting multi-step work that will land), so review/CI can track progress while local validation is still in flight. Pushes to a draft are free — the gate does not run on them. +- **Draft early**: open a draft PR as soon as there is a meaningful commit (or when starting multi-step work that will land), so review/CI can track progress while the rest of the work continues. Pushes to a draft run the same gate as any other push — the draft is about review status, not about skipping checks. - **Keep the PR current**: push commits as you go; update the PR body if scope shifts. - **Ready when done**: publish with `pnpm pr:ready`, or with plain `gh pr ready` — both run the ship gate first and undraft only if it passes. Do not hand-run the checks beforehand and do not leave a finished, validated change as draft. - **Base**: target `main` unless the work is explicitly stacked on another branch. @@ -22,16 +22,14 @@ Always open a PR for agent work that changes the repo — do not leave finished fires only for coding agents (`GROK_AGENT` / `T3_AGENT` / `AI_AGENT` / Claude / Cursor / Codex env markers). -| Branch PR state | Agent pre-push | -| ----------------------- | --------------------------------- | -| No open PR | **skip** — free push | -| **Draft** | **skip** — free push, share early | -| **Ready** for review | full ship gate | -| `gh` / PR lookup failed | full ship gate (**fail closed**) | +It runs on **every** agent push — draft, ready, or no PR at all. There is no +browser or API suite here that a draft could usefully defer, and a pushed commit +that does not compile or whose tests fail is worth nothing to a reviewer whatever +the PR says. The gate is `pnpm check` → `pnpm lint` → `pnpm test`, which is what CI runs and nothing more. This is a library monorepo — no application to stand up, no browser -suite — so the unit run *is* the gate. It caches the validated HEAD SHA in +suite — so the unit run _is_ the gate. It caches the validated HEAD SHA in `.run/agent-ship-gate.json`, so **one commit is validated once** however many times you push or publish it. Force a re-run with `AGENT_SHIP_GATE_FORCE=1`. @@ -79,11 +77,11 @@ Anti-patterns that mean you skipped the checklist: ### Validation -The ship gate owns validation — see *The gate runs the checks — you do not*. It -runs `pnpm check` → `pnpm lint` → `pnpm test` on publish and on pushes to a ready -PR, once per commit. +The ship gate owns validation — see _The gate runs the checks — you do not_. It +runs `pnpm check` → `pnpm lint` → `pnpm test` on every push and on publish, once +per commit. -`pnpm lint-fix` is the one thing worth running by hand, because it *writes*: it +`pnpm lint-fix` is the one thing worth running by hand, because it _writes_: it formats and auto-fixes across packages, and the gate only reports what it would have fixed. Run it when you are done editing, stage what it changes, then push. @@ -186,14 +184,14 @@ context. On an HTTP server, that MemoMap lives on the server fiber and is shared by every request that server handles. The first request to build a stateful layer (anything using `Layer.effect` / `Effect.acquireRelease`) memoizes the resulting value onto the server fiber; every subsequent request -then receives the *same instance* — including its `clear()` / dispose +then receives the _same instance_ — including its `clear()` / dispose finalizer, which now fires at the wrong time. When you call `Effect.provide(layer)` (or `Stream.provide(layer)`) inside a per-request hot path: - Pass `{ local: true }` if the layer is pure / stateless or you genuinely - want it scoped to *this* effect only, or + want it scoped to _this_ effect only, or - Build it explicitly against the request scope with a fresh `MemoMap` (`Layer.makeMemoMap` + `Layer.buildWithMemoMap(layer, memoMap, requestScope)`) — see `provideOnRequestScope` in `packages/infra/src/setupRequest.ts`. diff --git a/scripts/agent-pre-push.mjs b/scripts/agent-pre-push.mjs index 0d73b93b5..06bd125b0 100644 --- a/scripts/agent-pre-push.mjs +++ b/scripts/agent-pre-push.mjs @@ -4,11 +4,9 @@ * * Humans: no-op (exit 0) — self-responsible; not forced by the hook. * - * Agents: - * - Draft PR or no open PR: free push (open a draft early, push freely). - * - Ready-for-review PR (or unknown PR state): ship gate below. - * - Publishing runs the same gate whichever way it is reached: `pnpm pr:ready` - * explicitly, or raw `gh pr ready`, which the shim gates rather than refuses. + * Agents: every push runs the gate — draft, ready, or no PR at all. Publishing + * runs the same gate whichever way it is reached: `pnpm pr:ready` explicitly, or + * raw `gh pr ready`, which the shim gates rather than refuses. * * Ship gate — the JS quality path CI runs, and nothing else: * 1. `pnpm check` — tsgo --build ./tsconfig.all.json @@ -18,7 +16,10 @@ * This is a library monorepo: there is no application to stand up and no browser * suite, so the unit run is the whole story. That keeps the gate honest about * being the same thing CI runs (`ci.yml` runs `pnpm lint` and `pnpm test`) - * rather than a superset nobody else executes. + * rather than a superset nobody else executes — and it is why draft pushes are + * not exempted here: there is no expensive half to defer, only the checks that + * decide whether the pushed commit is worth anyone's attention. The SHA cache + * below keeps that from being paid twice. * * Checks run **once**, at the right moment: the validated HEAD SHA is cached * under `.run/`, so a commit is never re-validated because it was pushed twice. @@ -33,14 +34,7 @@ import path from "node:path" import process from "node:process" import { fileURLToPath } from "node:url" import { isCodingAgent } from "./lib/agent-env.mjs" -import { resolveOpenPrState, shouldRunShipGateOnPush } from "./lib/agent-pr-state.mjs" -import { - isShipGateForce, - isShipGateShaCached, - readHeadSha, - readShipGateCache, - writeShipGateCache -} from "./lib/agent-ship-gate-cache.mjs" +import { isShipGateForce, isShipGateShaCached, readHeadSha, readShipGateCache, writeShipGateCache } from "./lib/agent-ship-gate-cache.mjs" export { isCodingAgent } @@ -84,8 +78,8 @@ export const isWorktreeClean = (root = process.cwd(), opts = {}) => { } /** - * Shared by pre-push (ready PRs) and `pnpm pr:ready`. Caches the validated HEAD - * SHA so push + publish never double-run for the same commit. + * Shared by pre-push (every agent push) and `pnpm pr:ready`. Caches the + * validated HEAD SHA so push + publish never double-run for the same commit. * Force: AGENT_SHIP_GATE_FORCE=1. * * @param {{ root?: string, force?: boolean, env?: NodeJS.ProcessEnv }} [opts] @@ -139,23 +133,8 @@ const invokedAs = process.argv[1] ? path.resolve(process.argv[1]) : "" if (invokedAs === thisFile) { if (!isCodingAgent()) process.exit(0) - const root = process.cwd() - const prState = resolveOpenPrState({ cwd: root }) - - if (!shouldRunShipGateOnPush(prState.mode)) { - const why = prState.mode === "draft" - ? `draft PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""} — free push` - : "no open PR — free push (open a draft to share)" - console.error(`agent pre-push: skip ship gate (${why})`) - process.exit(0) - } - - if (prState.mode === "unknown") { - console.error( - `agent pre-push: PR state unknown (${prState.detail ?? "gh failed"}) — fail closed, running ship gate` - ) - } - - await runAgentShipGate({ root }) + // No PR-state lookup: the gate runs on every push, so what `gh` would say + // cannot change the outcome. + await runAgentShipGate({ root: process.cwd() }) process.exit(0) } diff --git a/scripts/lib/agent-pr-state.mjs b/scripts/lib/agent-pr-state.mjs index 976934679..5ea91a21d 100644 --- a/scripts/lib/agent-pr-state.mjs +++ b/scripts/lib/agent-pr-state.mjs @@ -1,11 +1,13 @@ /** - * Resolve whether the current branch's open PR should enforce the agent ship gate. + * Resolve the current branch's open PR state, for the publish path (`pnpm + * pr:ready` / the `gh` shim). Pushes do not consult it: the gate runs on all of + * them. * * Modes: - * none — no open PR (or closed/merged): free agent push - * draft — open draft PR: free agent push (GitHub Diff UI) - * ready — open non-draft PR: ship gate on every agent push - * unknown — gh missing / failed: fail closed (run the gate) + * none — no open PR (or closed/merged) + * draft — open draft PR: publishing undrafts it after the gate + * ready — open non-draft PR + * unknown — gh missing / failed: fail closed */ import { spawnSync } from "node:child_process" import process from "node:process" @@ -22,12 +24,6 @@ export const classifyPrPayload = (pr) => { return "ready" } -/** - * @param {"none" | "draft" | "ready" | "unknown"} mode - * @returns {boolean} - */ -export const shouldRunShipGateOnPush = (mode) => mode === "ready" || mode === "unknown" - /** * @param {{ * cwd?: string