Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 12 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`.
Expand Down
47 changes: 13 additions & 34 deletions scripts/agent-pre-push.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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 }

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
}
18 changes: 7 additions & 11 deletions scripts/lib/agent-pr-state.mjs
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down
Loading