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
6 changes: 5 additions & 1 deletion .envrc
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
use flake
use flake

# Repo `gh` policy shim: publishing runs the ship gate first (see AGENTS.md).
# Installed by scripts/install-git-hooks.mjs; run `direnv allow` once after cloning.
PATH_add .tools/bin
16 changes: 16 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Agent-only ship gate (humans: no-op). See scripts/agent-pre-push.mjs.
# Draft / no PR: free push. Ready PR: check + lint + test.
# Publish: pnpm pr:ready, or raw `gh pr ready` — the shim runs the same gate first.
#
# Humans: SKIP_AGENT_PREPUSH=1 git push
# Agents: never SKIP_AGENT_PREPUSH / never --no-verify
# Nested Git commands must discover their own repositories, not inherit this
# hook's GIT_DIR/GIT_WORK_TREE from the outer push.
for name in $(git rev-parse --local-env-vars); do
unset "$name"
done
# Plain `node`, not `pnpm exec node`: pnpm runs a dependency-status check first
# and will try to purge node_modules when the lockfile moved (a rebase does it),
# which aborts without a TTY — the hook then fails with a pnpm error instead of
# anything about the gate. The gate puts node_modules/.bin on PATH itself.
node scripts/agent-pre-push.mjs
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@ run.sh

# Generated publish staging directories
packages/*/.publish/

# agent gh shim (generated) + local ship-gate state
.tools/
.run/
48 changes: 41 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,40 @@ 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.
- **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.
- **Keep the PR current**: push commits as you go; update the PR body if scope shifts.
- **Ready when done**: after mandatory validation (`pnpm lint-fix`, `pnpm check`, and relevant tests) passes, mark the PR ready for review (undraft / publish). Do not leave a finished, validated change as draft.
- **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.

### The gate runs the checks — you do not

`.githooks/pre-push` is the agent ship gate. It is a **no-op for humans** and
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**) |

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
`.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`.

**Do not hand-run `pnpm check` / `lint` / `test` as routine verification.**
Validating the same commit repeatedly costs the same each time and proves nothing
the first run did not. While iterating, narrow proof is the right tool — the one
test you are fixing, or a typecheck of the package you touched. Whole-gate runs
belong to the push.

`.envrc` puts the `gh` shim on `PATH`; run **`direnv allow`** once after cloning
(`command -v gh` should print `.tools/bin/gh`). Never `--no-verify`, and never set
`SKIP_AGENT_PREPUSH` — that is the human escape hatch.

### Core Principles

- **Zero Tolerance for Errors**: All automated checks must pass
Expand Down Expand Up @@ -48,13 +77,18 @@ Anti-patterns that mean you skipped the checklist:
- Adding a sleep / retry to "give it time to work" instead of finding the missing wake signal.
- Disabling a hook (`--no-verify`) or a check to make the diff land.

### Mandatory Validation Steps
### 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.

After **all** changes are made, run these from the **repo root**:
`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.

1. `pnpm lint-fix` — auto-formats and fixes lint issues across all packages; apply all resulting changes
2. `pnpm check` — type-checks all packages (dependency changes in one package can break others); fix all reported errors
- If type checking continues to fail, run `pnpm clean` to clear caches, then re-run `pnpm check`
If type checking fails in a way that makes no sense against the diff, `pnpm clean`
clears the caches — stale incremental state produces phantom errors.

<!-- - Always run tests after making changes: `pnpm test <test_file.ts>` -->
<!-- - Build the project: `pnpm build`
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"lint": "pnpm -r lint",
"circular:dist": "pnpm -r circular:dist",
"test": "pnpm -r --no-bail test:run",
"pr:ready": "node scripts/agent-pr-ready.mjs",
"testsuite": "pnpm -r --no-bail testsuite",
"up-all": "pnpm --recursive update",
"u": "pnpm run update && pnpm i && pnpm dedupe",
Expand All @@ -34,7 +35,7 @@
"build": "cd packages/eslint-shared-config && pnpm build && cd ../.. && cd packages/cli && pnpm build && cd .. && pnpm build:tsc && cd vue-components && pnpm build",
"rbuild": "pnpm clean && pnpm build",
"nnm": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' + && pnpm i",
"prepare": "node -e \"const v=require('typescript/package.json').version;if(v.startsWith('6.')){require('child_process').execSync('effect-language-service patch',{stdio:'inherit'})}else{console.log('Skipping effect-language-service patch for TypeScript '+v)}\"",
"prepare": "node -e \"const v=require('typescript/package.json').version;if(v.startsWith('6.')){require('child_process').execSync('effect-language-service patch',{stdio:'inherit'})}else{console.log('Skipping effect-language-service patch for TypeScript '+v)}\" && node scripts/install-git-hooks.mjs",
"subtree:effect": "node scripts/sync-effect-subtree.js"
},
"dependencies": {
Expand Down
60 changes: 60 additions & 0 deletions scripts/agent-gh.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* Agent-facing `gh` policy shim.
*
* Installed at `.tools/bin/gh` by `scripts/install-git-hooks.mjs`.
* When coding-agent env markers are set and the command would publish a PR
* (`gh pr ready`, ready_for_review API), the ship gate runs first and the command
* then proceeds. Only a failing gate stops it — publishing is gated, not
* forbidden. `pnpm pr:ready` remains the explicit path; it sets AGENT_PR_SHIP=1
* so the gate runs once, not twice.
*
* Humans / non-agents: transparent pass-through to the next `gh` on PATH.
*/
import { spawnSync } from "node:child_process"
import path from "node:path"
import process from "node:process"
import { fileURLToPath } from "node:url"
import { isCodingAgent } from "./lib/agent-env.mjs"
import { findRealGh, requiresShipGate } from "./lib/agent-gh-policy.mjs"

const selfPath = fileURLToPath(import.meta.url)
const argv = process.argv.slice(2)

let shipping = false

if (isCodingAgent()) {
const gate = requiresShipGate(argv)
if (gate.required) {
console.error(`agent gh: ${gate.reason}`)
// Lazy: `gh` is on the hot path for ordinary commands and must not pay for
// the gate's dependencies just to run `gh pr view`.
const { runAgentShipGate } = await import("./agent-pre-push.mjs")
// Exits non-zero itself when a check fails, so a red gate stops the publish
// and a green one falls through to the real `gh` below.
await runAgentShipGate({ root: process.cwd() })
shipping = true
}
}

const realGh = findRealGh({ selfPath })
if (!realGh) {
console.error("agent gh: could not resolve real `gh` binary (set AGENT_GH_REAL)")
process.exit(127)
}

// Avoid re-entering this shim if PATH still prefers us.
const env = { ...process.env }
const toolsBin = path.resolve(path.dirname(selfPath), "..", ".tools", "bin")
const pathParts = (env["PATH"] ?? "").split(path.delimiter).filter(Boolean)
env["PATH"] = pathParts.filter((p) => path.resolve(p) !== toolsBin).join(path.delimiter)
env["AGENT_GH_REAL"] = realGh
// The gate passed, so anything this command spawns is already cleared to ship.
if (shipping) env["AGENT_PR_SHIP"] = "1"

const result = spawnSync(realGh, argv, {
stdio: "inherit",
env,
shell: false
})
process.exit(result.status === null ? 1 : result.status)
65 changes: 65 additions & 0 deletions scripts/agent-pr-ready.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Agent (and human) publish path: run the ship gate, then mark the PR ready.
*
* pnpm pr:ready
*
* Agents must not call `gh pr ready` directly — the agent gh shim blocks it
* unless AGENT_PR_SHIP=1 (set only here for the undraft step).
*/
import { spawnSync } from "node:child_process"
import process from "node:process"
import { runAgentShipGate } from "./agent-pre-push.mjs"
import { resolveOpenPrState } from "./lib/agent-pr-state.mjs"

const root = process.cwd()
const prState = resolveOpenPrState({ cwd: root })

if (prState.mode === "none") {
console.error("agent pr:ready: no open PR for this branch — open a draft first (`gh pr create --draft`)")
process.exit(1)
}

if (prState.mode === "unknown") {
console.error(`agent pr:ready: cannot resolve PR state (${prState.detail ?? "gh failed"})`)
process.exit(1)
}

if (prState.mode === "ready") {
console.error(
`agent pr:ready: PR${
prState.pr?.number != null ? ` #${prState.pr.number}` : ""
} is already ready — running ship gate only`
)
await runAgentShipGate({ root })
process.exit(0)
}

// draft → gate then undraft
console.error(
`agent pr:ready: ship gate then ready PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""}`
)
await runAgentShipGate({ root })

const env = { ...process.env, AGENT_PR_SHIP: "1" }
const readyArgs = prState.pr?.number != null
? ["pr", "ready", String(prState.pr.number)]
: ["pr", "ready"]

console.error("agent pr:ready: marking PR ready for review")
const result = spawnSync("gh", readyArgs, {
stdio: "inherit",
cwd: root,
env,
shell: false
})
const status = result.status === null ? 1 : result.status
if (status !== 0) {
console.error(`agent pr:ready: gh pr ready failed (exit ${status})`)
process.exit(status)
}

console.error(
"agent pr:ready: ok — PR is ready; CI re-runs with e2e enabled (ready_for_review; not draft mini baseline)"
)
process.exit(0)
Loading
Loading