diff --git a/.envrc b/.envrc new file mode 100644 index 00000000000..9416b3bc675 --- /dev/null +++ b/.envrc @@ -0,0 +1,9 @@ +# Prefer the repo gh policy shim so coding agents cannot undraft with raw +# `gh pr ready` (use `pnpm pr:ready`). Installed by scripts/install-git-hooks.mjs. +PATH_add .tools/bin + +# Default bare `gh` commands to the fork, not gh's upstream-parent default +# (`pingdotgg/t3code`). Without this, `gh pr view`/`create`/`checks` resolve +# against upstream and miss the fork's PRs. Explicit `--repo` always overrides, +# so the stack tooling (which always passes `--repo`) is unaffected. +export GH_REPO="${T3CODE_FORK_REPOSITORY:-patroza/t3code}" diff --git a/.gitignore b/.gitignore index c5ff818d59e..2d47778818b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,9 @@ node_modules/ *.log .env* !.env.example +!.envrc +# direnv +.direnv +# agent gh shim + local run state (ship-gate cache, etc.) +.tools/ +.run/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000000..cb2c84d5c3c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000000..620b99606be --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,7 @@ +# Agent-only ship gate (humans: no-op). See scripts/agent-pre-push.mjs. +# Draft / no PR: free agent push. Ready PR: vp check + typecheck + tests. +# Publish: pnpm pr:ready (not raw gh pr ready). Agent gh shim: .tools/bin/gh. +# +# Humans: SKIP_AGENT_PREPUSH=1 git push +# Agents: never SKIP_AGENT_PREPUSH / never --no-verify +pnpm exec node scripts/agent-pre-push.mjs diff --git a/AGENTS.md b/AGENTS.md index f4b87afae94..c31ac279810 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,8 +145,10 @@ Day-to-day ship path (compose, not restack): [docs/stack-ship-path.md](./docs/st (“Per-layer full CI after stack rebase”). - Fix **all** failures on that layer, commit, force-with-lease push if the layer is shared, then and only then advance. - - Same stop-the-line rule for feature / overlay-child PRs after `pnpm fork:stack update`: rebase - onto the fixed parent, run the full pre-push gate on the feature tip, then push/merge. + - Feature / overlay-child PRs after `pnpm fork:stack update`: rebase onto the fixed parent, then + let the automated agent ship gate validate the tip — a ready-PR push runs it, or publish with + `pnpm pr:ready`. Only stack-layer rewrites (protected `fork/*` tips, not PR pushes) run the + fuller per-layer manual gate below. - **Conflict resolutions (required when stack hits conflicts):** do **not** only hand-resolve and resume. Update `.github/pr-stack.json` `conflictResolutions` so the next sync auto-applies the same side. Prefer durable `commit: "*"` + path policies; exact SHAs go stale after every rewrite. @@ -185,30 +187,33 @@ When implementation work for a user request is done (code, docs, config — not - Ordinary features → **`fork/changes`** (never `main`, never `fork/integration`). - Client overlay work → the **registered overlay branch** (`fork/discord`, `fork/vscode`, `fork/identity`, or `t3-discord/f7d37879-desktop-deeplinks`), not a duplicate of that work in `fork/changes`. -3. **Keep the PR mergeable** before saying “updated the PR” or finishing: - - **Mandatory pre-push gate** (see Task Completion Requirements): run **locally every job Fork - CI will run on this tip** — at minimum **`vp check`** and the **full monorepo typecheck** — - fix every failure (including pre-existing breakage your tip inherits from the base), then - push. Do not use Fork CI as the first formatter, linter, or typechecker. Scoped package - typecheck alone is **not** enough. - - **Same bar for overlay-child PRs.** Base = overlay does **not** relax the gate. Compose - success, draft-lock green, or “the permanent overlay PR was green last week” is **not** a - substitute for running Check on **this** tip. - - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` +3. **Let the agent ship gate own validation** before saying “updated the PR” or finishing (see + _Task Completion Requirements → Agent ship gate_): + - **Push freely while the PR is a draft.** The gate is a no-op on draft / no-PR pushes — use them + for early sharing and the GitHub Diff UI. Do **not** hand-run `vp check` / `vpr typecheck` / + `vp run test` as routine draft validation (focused, scoped proof while iterating is still fine). + - **Publish with `pnpm pr:ready`, never raw `gh pr ready`.** It runs the ship gate + (`vp check` → `vpr typecheck` → `vp run test`) and only then marks the PR ready. The husky + `pre-push` hook enforces the same gate automatically on every push to a ready PR and fails + closed when PR state can’t be resolved; the `.tools/bin/gh` shim blocks agent `gh pr ready`. + - **Same gate for overlay-child PRs.** Base = overlay does **not** relax it; the gate keys off the + PR’s ready state, not its base. Compose success or draft-lock green is **not** the gate. + - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` to + rebase/retarget; the ensuing push runs the ship gate when the PR is ready. - Confirm with `gh pr view --json baseRefName,mergeable,mergeStateStatus,url` - `baseRefName` must be `fork/changes` for ordinary features or the intended overlay/parent branch for a dependent/overlay-child PR. `mergeable` should be `MERGEABLE` (CI may still be `UNSTABLE` while checks run). 4. **Before pushing follow-ups**, verify PR state with `gh pr view` (or equivalent): - - If the PR is **open** → re-run the mandatory pre-push gate, update that branch (prefer - `fork:stack update --push`), and push. + - If the PR is **open** → update that branch (prefer `fork:stack update --push`) and push; a ready + PR re-runs the ship gate automatically, a draft pushes free. - If the PR is **merged** or **closed** → do **not** keep committing on that branch. Start a new branch, re-apply unmerged work, and open a **new PR** against the same intended base (`fork/changes` or the overlay). 5. Never assume an earlier PR in the session is still open. -6. **Never merge or request merge** (and never tell a bot to merge) while local `vp check` / - full typecheck are red or were skipped. GitHub required checks on `fork/changes` and every - registered overlay base are a backstop — **local green first** is still mandatory. +6. **Never merge or request merge** (and never tell a bot to merge) a PR that has not passed the ship + gate — publish through `pnpm pr:ready` first. GitHub required checks on `fork/changes` and every + registered overlay base are the backstop; the ship gate is what runs first. ## Discord-originated commits (REQUIRED) @@ -225,7 +230,7 @@ GitHub multi-author avatars (`bot & human`) come from commit trailers, not from When Discord work produces commits (or is clearly intended to land): -0. **Always open a PR — do not wait for perfect green.** Create the PR as soon as there is something to review or track. If full lint / typecheck / focused tests / `vp check` are not finished yet, open it as a **draft**. Convert to ready for review / merge **only** after those gates pass locally. A missing PR while work sits only on a remote branch is incomplete handoff. **Draft is for tracking, not for merging:** do not squash-merge, rebase-merge, or instruct a bot to merge a draft or any tip that has not passed the local gate. +0. **Always open a PR — do not wait for perfect green.** Create the PR as a **draft** as soon as the first meaningful commit gives reviewers something to inspect, and push freely while it stays draft. A missing PR while work sits only on a remote branch is incomplete handoff. **Publish only when done, with `pnpm pr:ready`** — it runs the agent ship gate, then undrafts. **Draft is for tracking, not for merging:** do not squash-merge, rebase-merge, or instruct a bot to merge a draft or any tip that has not passed the ship gate. When opening or updating a PR from a Discord thread: @@ -243,92 +248,92 @@ Prefer the thread starter’s Discord id/display name from turn context. Do not ## Task Completion Requirements -### Mandatory pre-push / PR handoff gate (no exceptions) - -**Whatever Fork CI runs for this tip, the agent must run locally first.** Fork CI is a safety net, -not the first formatter, linter, or typechecker. This applies to **every** implementation base: - -| PR base | Local gate required before ready / merge? | GitHub required checks | -| -------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------- | -| `fork/changes` | **Yes** — full gate below | Check, Test, Mobile Native Static Analysis, Release Smoke | -| Registered overlay (`fork/discord`, `fork/vscode`, `fork/identity`, desktop deeplinks) | **Yes — identical** | Same as `fork/changes` | -| Dependent feature based on another feature branch | **Yes** on the child tip after rebase | Same when retargeted to a protected base | - -**Before every `git push` that is intended as ready work, `fork:stack update --push`, non-draft PR -open, ready-for-review conversion, merge / merge-request, or “handoff / done” claim**, the agent -**must** run the local gates that mirror Fork CI’s **Check** job (and Test pieces you changed), -fix all failures, then push. - -**Always open a PR for Discord/agent work that produces commits** (see _Discord-originated pull -requests_). **Draft PR exception:** you may open/update a **draft** PR earlier for tracking once -commits exist, co-author trailers are correct, and focused tests for the changed behavior have been -run — even if full monorepo typecheck / root `vp check` are still in progress. Do not claim the work -is ready, mark the PR non-draft, or merge until the full gate below passes. - -#### Why integration keeps failing on “obvious” lint - -These are process failures, not surprises: - -1. Child PR base was an overlay and agents treated Compose / permanent-overlay PR green as enough. -2. Local `vp check` was skipped (“CI will catch it” / only scoped package checks). -3. Compose rebuilds `fork/integration` **without** re-running lint — the first hard fail is - integration Fork CI. - -If a PR’s checks panel shows only Compose / draft-lock and **no** Check job, that is **not** a -clean PR — fix tooling or still run the local gate; do not merge. - -Run from the repository root, in order (mirror of `.github/workflows/fork-ci.yml`): - -1. **`vp check`** — exact formatter/linter gate used by Fork CI **Check** (includes - `t3code/namespace-node-imports` and friends). A focused format/lint while iterating is fine; it - is **not** a substitute for this root command before ready handoff. -2. **Full monorepo typecheck** (matches Fork CI): - - ```bash - ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run -r --cache --log labeled typecheck - ``` - - Equivalent: `vp run typecheck` / root `pnpm` typecheck script that runs recursive package - typechecks. **Scoped** typecheck of only the package you edited is allowed **while iterating**, - but **before ready handoff you must run the full recursive typecheck**. Failures in packages you - did not touch still block: your tip inherits the base; fix or land a fix on the tip so CI is green. - -3. **Desktop Check pieces when the tip can break them** (Fork CI **Check** also runs these): after - desktop or preload-adjacent changes, run `vp run --cache build:desktop` and the preload verify - steps from `.github/workflows/fork-ci.yml`. When in doubt on a stack layer rewrite, run them. -4. **Focused tests for behavior you changed** (not always the full workspace suite — see stack - rule below): - - `vp test run ` for built-in Vite+ tests, or the package’s `test` script when that - is what the package uses. - - Backend / contracts / runtime behavior changes **must** include and run focused tests for the - changed behavior. - - Fork product / UI changes **must** include an existence or behavior assertion that fails if - the surface is dropped (not only pure helpers). See `apps/web/src/forkSurfaceExistence.test.ts` - and [docs/fork-stack.md](./docs/fork-stack.md) (“Product conflicts”). -5. **Do not push a ready (non-draft) handoff** if steps 1–2 fail, or if required steps 3–4 fail. - Fix first. - -**Ordinary feature PRs (based on `fork/changes` or an overlay):** full-workspace `vp run test` is -optional unless the user asks or the change clearly needs the whole suite. **Do not** skip steps -1–2 to save time on ready handoff. **Stack layer rewrites** still require full Test (below). - -**Explicitly forbidden before ready handoff / merge:** - -- Ready/non-draft push after only unit tests, only scoped package typecheck, or only a partial lint. -- Marking a PR ready for review knowing typecheck or `vp check` was skipped or red. -- Treating “CI will catch it”, “compose will catch it”, or “integration CI will catch it” as a - substitute for local gates. -- Merging an overlay-child PR because Compose or Managed PR draft lock is green while Check never - ran or is red. -- Advancing a stack rewrite to the next layer while the current layer is red (see below). -- Leaving Discord/agent work with commits but **no** PR (use draft until gates finish). - -While iterating mid-task (not yet ready), keep feedback loops small: format/lint the files you -touch, typecheck the packages you edit, run the smallest relevant tests. **The bar rises to the -full pre-push gate the moment you mark ready, merge, or claim done.** +### Agent ship gate (draft-free push, gate on ready / publish) + +Publish validation is **automated**. Agents do **not** hand-run the linter/typechecker/tests as a +routine pre-handoff ritual — the ship gate runs them once, at the right moment, and caches the +result. Push freely while drafting; the gate fires on ready PRs and on `pnpm pr:ready`. + +**Mechanism** (`scripts/agent-pre-push.mjs`, `scripts/agent-pr-ready.mjs`, `scripts/lib/*.mjs`): + +- Husky `pre-push` is the agent ship gate. It is a **no-op for humans** and only fires for coding + agents (detected via `GROK_AGENT` / `T3_AGENT` / `AI_AGENT` / `CLAUDECODE` / Cursor / Codex env + markers). Humans opt out per push with `SKIP_AGENT_PREPUSH=1` — **agents never set that flag** and + never use `git push --no-verify`. +- When it runs, the gate mirrors the CI JS quality path, in order: + 1. **`vp check`** — format + lint (the Fork CI **Check** JS path) + 2. **`vpr typecheck`** — workspace TypeScript + 3. **`vp run test`** — unit tests + Cargo, mobile native, desktop packaging, and Release Smoke stay **CI-only** — do not hand-run them + for ordinary PR handoff. +- It **skips when the HEAD SHA is already cached** in `.run/agent-ship-gate.json`, so a push and the + publish step never double-run for the same commit. Force with `AGENT_SHIP_GATE_FORCE=1`. + +**When it runs:** + +| Branch PR state | Agent push | +| ---------------------- | ----------------------------------------- | +| No open PR | **free push** (open a draft to share) | +| **Draft** PR | **free push** (review via GitHub Diff UI) | +| **Ready** PR | full ship gate on every push | +| PR state can’t resolve | full ship gate (**fail closed**) | + +This is identical for `fork/changes`, every registered overlay base (`fork/discord`, `fork/vscode`, +`fork/identity`, desktop deeplinks), and dependent / overlay-child PRs — the gate keys off the PR’s +**ready state**, not its base. Overlay Compose success or Managed-PR draft-lock green is **not** the +gate. + +**Publish path (required):** `pnpm pr:ready` — runs the ship gate, then marks the open draft PR +ready. Agents must **not** call raw `gh pr ready` (or the ready-for-review API); the `.tools/bin/gh` +policy shim blocks it unless `AGENT_PR_SHIP=1` (set only by `pr:ready`). `.envrc` puts +`$REPO/.tools/bin` first on `PATH` so the shim wins over system `gh`, and sets `GH_REPO` to the fork +so bare `gh pr` commands target it instead of gh's upstream-parent default (explicit `--repo` still +overrides); `scripts/install-git-hooks.mjs` installs both the hooks and the shim on `prepare`. + +**Agent workflow:** + +- **Open the draft immediately** once a PR is in scope (user asked, or Discord/turn rules require it) + and the first meaningful commit is useful to review. Keep committing and pushing freely while it is + a draft — no separate validation is expected on draft pushes. +- **Publish when the work is done — do not leave a finished PR in draft.** The moment implementation + is complete and verified, run `pnpm pr:ready` to run the ship gate and mark the PR ready; this is + the immediate next action, before handoff notes, so full CI can start. Draft is only for + work-in-progress. Do **not** run lint / check / typecheck / tests separately first — the gate owns + the complete JS validation and caches the passing SHA. +- **Mid-loop feedback only:** while drafting, focused proof is fine — `vp test run ` for tests + you touched, targeted lint/typecheck for the scope you changed. That is edit-loop signal, not a + second gate. +- Fork product / UI changes still **must** ship an existence or behavior assertion that fails if the + surface is dropped (not only pure helpers) — see `apps/web/src/forkSurfaceExistence.test.ts` and + [docs/fork-stack.md](./docs/fork-stack.md) (“Product conflicts”). The gate’s `vp run test` then + actually exercises it. +- Backend / contracts / runtime behavior changes **must** land with focused tests for the changed + behavior; the gate’s `vp run test` runs them. + +Pre-commit: husky runs `pnpm lint-staged` — `vp fmt` on all staged files plus `vp lint --fix` on +staged code files (format + lint on commit; typecheck and tests stay in the ship gate). If the hook +rewrites files, stage those rewrites, commit, and push again. + +**Explicitly forbidden:** + +- `git push --no-verify` / `git commit --no-verify`, or setting `SKIP_AGENT_PREPUSH` (human escape + hatch only). +- Raw `gh pr ready` / the ready-for-review API to undraft — always `pnpm pr:ready`. +- Merging or requesting merge (including telling a bot to merge) a draft or any tip that has not + passed the ship gate. +- Treating Compose, Managed-PR draft-lock, or “integration CI will catch it” as a substitute for + publishing through the gate. If a PR’s checks panel shows only Compose / draft-lock and **no** + Check job, it has not been through the gate — publish with `pnpm pr:ready`. +- Leaving Discord/agent work with commits but **no** PR (open a draft; pushes stay free). +- Leaving a **finished** PR in draft. Draft is for work-in-progress only; when the work is done, + publish it with `pnpm pr:ready` — a completed PR sitting in draft is an incomplete handoff. ### Per-layer stack CI (stop the line — no exceptions) +The automated agent ship gate covers **PR pushes**. Stack-layer rewrites push protected `fork/*` +tips directly — they are **not** PR pushes, so the husky gate does not fire and this fuller manual +gate is mandatory instead. + When rebasing, replaying, conflict-resolving, or otherwise rewriting **any** fork stack layer (`fork/tim`, `fork/candidates`, `fork/changes`, an integration overlay, or composed `fork/integration`): @@ -370,9 +375,9 @@ main (upstream mirror — do not hand-edit product fixes) - **Never** stack “green later” commits, push a known-red parent, or compose `fork/integration` from layers that have not each passed the full gate. - **Never** treat “the next layer will fix typecheck/lint/tests” as acceptable progress. -- Feature PRs and overlay children: after rebasing onto a parent, the **child tip** must also pass - the ordinary pre-push gate (and stack-layer full test gate if you are rewriting stack automation - itself) before push. +- Feature PRs and overlay children: after rebasing onto a parent, the **child tip** goes through the + automated agent ship gate (ready-PR push or `pnpm pr:ready`) — plus this stack-layer full test gate + if you are rewriting stack automation itself. Full narrative and examples: [docs/fork-stack.md](./docs/fork-stack.md) (“Per-layer full CI after stack rebase”). diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index 2a020701064..959d5e1fe05 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -58,6 +58,15 @@ authenticated. - `vp run typecheck`: Strict TypeScript checks for all packages. - `vp run test`: Runs workspace tests. - `vp run lint:mobile`: Mobile native static analysis (`scripts/mobile-native-static-check.ts`). +- `pnpm pr:ready`: Agent publish path — runs the ship gate (`vp check`, `vpr typecheck`, + `vp run test`), then marks the open draft PR ready. Do not use raw `gh pr ready` from coding + agents; the `.tools/bin/gh` shim blocks undraft side channels (installed by + `scripts/install-git-hooks.mjs` on `prepare`). +- `pnpm test:agent-gate`: Unit tests for the agent pre-push / PR-state / gh-policy helpers. +- Husky: `pre-commit` runs `pnpm lint-staged` (`vp fmt` on staged files + `vp lint --fix` on staged + code files); + `pre-push` runs `scripts/agent-pre-push.mjs` (agents only: free push on draft / no PR; full ship + gate on ready PRs; SHA cache in `.run/agent-ship-gate.json`). - `node apps/server/scripts/t3-sqlite-state.ts --base-dir ...`: Inspects or seeds an isolated T3 SQLite database; writes create a private backup first. diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 00000000000..504aa7b8094 --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,11 @@ +/** @type {import("lint-staged").Configuration} */ +export default { + // Keep in sync with vite.config.ts `staged`. + // Commit runs format + lint; the heavier typecheck + tests stay in the agent + // ship gate (pre-push on ready PRs / `pnpm pr:ready`). + // `--no-error-on-unmatched-pattern`: a commit whose staged files are all + // unformattable (e.g. only *.nix) must not fail pre-commit. + "*": "vp fmt --no-error-on-unmatched-pattern", + // Lint (with autofix) only the code files oxlint understands. + "*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": "vp lint --fix", +}; diff --git a/package.json b/package.json index d46251d5c67..385dc1a8d5d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "private": true, "type": "module", "scripts": { - "prepare": "node scripts/clean-tsgo-backups.mjs && effect-tsgo patch && vp config --no-agent", + "prepare": "node scripts/clean-tsgo-backups.mjs && effect-tsgo patch && vp config --no-agent && node scripts/install-git-hooks.mjs", + "pr:ready": "node scripts/agent-pr-ready.mjs", + "test:agent-gate": "vp test run scripts/agent-pre-push.test.mjs", "dev": "node scripts/dev-runner.ts dev", "dev:share": "node scripts/dev-runner.ts dev --share", "dev:server": "node scripts/dev-runner.ts dev:server", @@ -55,6 +57,8 @@ "@oxlint/plugins": "^1.63.0", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", "vite-plus": "catalog:" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 464a23b6827..3fdf0c898af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,12 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260604.1 + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -5839,6 +5845,9 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -7212,6 +7221,11 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -7784,6 +7798,15 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} @@ -7812,6 +7835,10 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -9274,6 +9301,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -9483,6 +9513,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + slice-ansi@8.0.0: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} @@ -9587,6 +9621,10 @@ packages: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -16152,6 +16190,8 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -17883,6 +17923,8 @@ snapshots: transitivePeerDependencies: - supports-color + husky@9.1.7: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -18352,6 +18394,24 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + yaml: 2.9.0 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + locate-path@3.0.0: dependencies: p-locate: 3.0.0 @@ -18376,6 +18436,14 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.1.2 + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + long@5.3.2: {} longest-streak@3.1.0: {} @@ -20390,6 +20458,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rimraf@2.6.3: dependencies: glob: 7.2.3 @@ -20752,6 +20822,11 @@ snapshots: sisteransi@1.0.5: {} + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slice-ansi@8.0.0: dependencies: ansi-styles: 6.2.3 @@ -20827,6 +20902,8 @@ snapshots: strict-uri-encode@2.0.0: {} + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 diff --git a/scripts/agent-gh.mjs b/scripts/agent-gh.mjs new file mode 100644 index 00000000000..1ab6ab3fcff --- /dev/null +++ b/scripts/agent-gh.mjs @@ -0,0 +1,48 @@ +#!/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, blocks undraft side channels + * (`gh pr ready`, ready_for_review API). Use `pnpm pr:ready` instead + * (sets AGENT_PR_SHIP=1 for the real call). + * + * Humans / non-agents: transparent pass-through to the next `gh` on PATH. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import * as NodeURL from "node:url"; +import { isCodingAgent } from "./lib/agent-env.mjs"; +import { findRealGh, inspectAgentGhCommand } from "./lib/agent-gh-policy.mjs"; + +const selfPath = NodeURL.fileURLToPath(import.meta.url); +const argv = NodeProcess.argv.slice(2); + +if (isCodingAgent()) { + const decision = inspectAgentGhCommand(argv); + if (decision.blocked) { + console.error(`agent gh: blocked: ${decision.reason}`); + NodeProcess.exit(1); + } +} + +const realGh = findRealGh({ selfPath }); +if (!realGh) { + console.error("agent gh: could not resolve real `gh` binary (set AGENT_GH_REAL)"); + NodeProcess.exit(127); +} + +// Avoid re-entering this shim if PATH still prefers us. +const env = { ...NodeProcess.env }; +const toolsBin = NodePath.resolve(NodePath.dirname(selfPath), "..", ".tools", "bin"); +const pathParts = (env["PATH"] ?? "").split(NodePath.delimiter).filter(Boolean); +env["PATH"] = pathParts.filter((p) => NodePath.resolve(p) !== toolsBin).join(NodePath.delimiter); +env["AGENT_GH_REAL"] = realGh; + +const result = NodeChildProcess.spawnSync(realGh, argv, { + stdio: "inherit", + env, + shell: false, +}); +NodeProcess.exit(result.status === null ? 1 : result.status); diff --git a/scripts/agent-pr-ready.mjs b/scripts/agent-pr-ready.mjs new file mode 100644 index 00000000000..08cf34fe708 --- /dev/null +++ b/scripts/agent-pr-ready.mjs @@ -0,0 +1,62 @@ +#!/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 * as NodeChildProcess from "node:child_process"; +import * as NodeProcess from "node:process"; +import { runAgentShipGate } from "./agent-pre-push.mjs"; +import { resolveOpenPrState } from "./lib/agent-pr-state.mjs"; + +const root = NodeProcess.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`)", + ); + NodeProcess.exit(1); +} + +if (prState.mode === "unknown") { + console.error(`agent pr:ready: cannot resolve PR state (${prState.detail ?? "gh failed"})`); + NodeProcess.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 }); + NodeProcess.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 = { ...NodeProcess.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 = NodeChildProcess.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})`); + NodeProcess.exit(status); +} + +console.error("agent pr:ready: ok — PR is ready; CI will run the full suite"); +NodeProcess.exit(0); diff --git a/scripts/agent-pre-push.mjs b/scripts/agent-pre-push.mjs new file mode 100644 index 00000000000..0560484ddaa --- /dev/null +++ b/scripts/agent-pre-push.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Husky pre-push entry for coding agents only. + * + * Humans: no-op (exit 0) — self-responsible; not forced by the hook. + * + * Agents: + * - Draft PR or no open PR: free push (GitHub Diff UI / early share). + * - Ready-for-review PR (or unknown PR state): ship gate below. + * - Publish path is `pnpm pr:ready` (not raw `gh pr ready`) — same gate, + * then undraft. See scripts/agent-pr-ready.mjs + scripts/agent-gh.mjs. + * + * Ship gate (when enforced) mirrors the CI JS quality path: + * 1. `vp check` — format + lint + * 2. `vpr typecheck` — workspace TypeScript + * 3. `vp run test` — unit tests + * + * Detection: GROK_AGENT / T3_AGENT / AI_AGENT / Claude / Cursor / Codex env. + * Humans only: SKIP_AGENT_PREPUSH=1 git push + * Agents must never set that flag or use git push --no-verify. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import * as NodeURL 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"; + +export { isCodingAgent }; + +const run = (label, args, opts = {}) => { + console.error(`agent ship-gate: ${label}`); + const result = NodeChildProcess.spawnSync(args[0], args.slice(1), { + stdio: "inherit", + shell: true, + cwd: opts.cwd ?? NodeProcess.cwd(), + env: opts.env ?? NodeProcess.env, + }); + const status = result.status === null ? 1 : result.status; + if (status !== 0) { + console.error(`agent ship-gate: failed: ${label} (exit ${status})`); + NodeProcess.exit(status); + } +}; + +/** + * Full agent ship gate: check + typecheck + unit tests. + * Shared by pre-push (ready PRs) and `pnpm pr:ready`. + * Caches HEAD SHA under `.run/agent-ship-gate.json` so push + ready never + * double-run for an already-validated commit. Force: AGENT_SHIP_GATE_FORCE=1. + * + * @param {{ root?: string, force?: boolean, env?: NodeJS.ProcessEnv }} [opts] + * @returns {Promise<{ status: "cached" | "ok", sha: string | null }>} + */ +export const runAgentShipGate = async (opts = {}) => { + const root = opts.root ?? NodeProcess.cwd(); + const env = opts.env ?? NodeProcess.env; + const force = opts.force === true || isShipGateForce(env); + const headSha = readHeadSha(root); + + if (!force && headSha && isShipGateShaCached(headSha, readShipGateCache(root))) { + console.error( + `agent ship-gate: skip — ${headSha.slice(0, 12)} already validated (cache .run/agent-ship-gate.json; force with AGENT_SHIP_GATE_FORCE=1)`, + ); + return { status: "cached", sha: headSha }; + } + + // Mirror CI check + test jobs (JS path). Cargo/mobile/desktop builds stay CI-only. + run("vp check", ["vp", "check"], { cwd: root, env }); + run("vpr typecheck", ["vpr", "typecheck"], { cwd: root, env }); + run("vp run test", ["vp", "run", "test"], { cwd: root, env }); + + if (headSha) { + try { + writeShipGateCache(root, headSha); + console.error(`agent ship-gate: cached ${headSha.slice(0, 12)} (.run/agent-ship-gate.json)`); + } catch (error) { + console.error(`agent ship-gate: could not write cache: ${error?.message ?? error}`); + } + } + + console.error("agent ship-gate: ok"); + return { status: "ok", sha: headSha }; +}; + +const thisFile = NodeURL.fileURLToPath(import.meta.url); +const invokedAs = NodeProcess.argv[1] ? NodePath.resolve(NodeProcess.argv[1]) : ""; + +if (invokedAs === thisFile) { + if (!isCodingAgent()) { + NodeProcess.exit(0); + } + + const root = NodeProcess.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 (ship gate runs on publish via pnpm pr:ready)` + : "no open PR — free push (open a draft to share; ship gate on ready PRs / pnpm pr:ready)"; + console.error(`agent pre-push: skip ship gate (${why})`); + NodeProcess.exit(0); + } + + if (prState.mode === "unknown") { + console.error( + `agent pre-push: PR state unknown (${prState.detail ?? "gh failed"}) — fail closed, running ship gate`, + ); + } else { + console.error( + `agent pre-push: ready PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""} — running ship gate`, + ); + } + + await runAgentShipGate({ root }); + NodeProcess.exit(0); +} diff --git a/scripts/agent-pre-push.test.mjs b/scripts/agent-pre-push.test.mjs new file mode 100644 index 00000000000..8b55c622c16 --- /dev/null +++ b/scripts/agent-pre-push.test.mjs @@ -0,0 +1,221 @@ +import { assert, it } from "@effect/vitest"; +import { isCodingAgent } from "./agent-pre-push.mjs"; +import { inspectAgentGhCommand, stripGhGlobalFlags } from "./lib/agent-gh-policy.mjs"; +import { + classifyPrPayload, + parseRepoSlug, + resolveOpenPrState, + shouldRunShipGateOnPush, +} from "./lib/agent-pr-state.mjs"; +import { + isShipGateForce, + isShipGateShaCached, + readShipGateCache, + writeShipGateCache, +} from "./lib/agent-ship-gate-cache.mjs"; + +it("humans: empty env is not an agent", () => { + assert.equal(isCodingAgent({}), false); +}); + +it("humans: SKIP_AGENT_PREPUSH wins even if GROK_AGENT is set", () => { + assert.equal(isCodingAgent({ GROK_AGENT: "1", SKIP_AGENT_PREPUSH: "1" }), false); +}); + +it("agents: GROK_AGENT / T3_AGENT / AI_AGENT", () => { + assert.equal(isCodingAgent({ GROK_AGENT: "1" }), true); + assert.equal(isCodingAgent({ T3_AGENT: "1" }), true); + assert.equal(isCodingAgent({ AI_AGENT: "1" }), true); +}); + +it("agents: Claude / Cursor / Codex markers", () => { + assert.equal(isCodingAgent({ CLAUDECODE: "1" }), true); + assert.equal(isCodingAgent({ CURSOR_AGENT: "1" }), true); + assert.equal(isCodingAgent({ CODEX_CI: "1" }), true); +}); + +it("truthy: 0 / false / no are not agents", () => { + assert.equal(isCodingAgent({ GROK_AGENT: "0" }), false); + assert.equal(isCodingAgent({ GROK_AGENT: "false" }), false); +}); + +it("PR payload: draft / ready / closed", () => { + assert.equal(classifyPrPayload(null), "none"); + assert.equal(classifyPrPayload({ isDraft: true, state: "OPEN" }), "draft"); + assert.equal(classifyPrPayload({ isDraft: false, state: "OPEN" }), "ready"); + assert.equal(classifyPrPayload({ isDraft: false, state: "MERGED" }), "none"); + assert.equal(classifyPrPayload({ isDraft: true, state: "CLOSED" }), "none"); +}); + +it("ship gate on push: only ready + unknown", () => { + assert.equal(shouldRunShipGateOnPush("none"), false); + assert.equal(shouldRunShipGateOnPush("draft"), false); + assert.equal(shouldRunShipGateOnPush("ready"), true); + assert.equal(shouldRunShipGateOnPush("unknown"), true); +}); + +const pinned = (runGh) => ({ branch: "feature", repoSlug: "owner/repo", runGh }); + +it("parseRepoSlug: ssh / https / trailing .git", () => { + assert.equal(parseRepoSlug("git@github.com:patroza/t3code.git"), "patroza/t3code"); + assert.equal(parseRepoSlug("https://github.com/patroza/t3code.git"), "patroza/t3code"); + assert.equal(parseRepoSlug("https://github.com/patroza/t3code"), "patroza/t3code"); + assert.equal(parseRepoSlug("ssh://git@github.com/patroza/t3code.git"), "patroza/t3code"); + assert.equal(parseRepoSlug(""), null); + assert.equal(parseRepoSlug(null), null); +}); + +it("resolveOpenPrState: empty list → none", () => { + const state = resolveOpenPrState(pinned(() => ({ status: 0, stdout: "[]", stderr: "" }))); + assert.equal(state.mode, "none"); +}); + +it("resolveOpenPrState: draft list → draft", () => { + const state = resolveOpenPrState( + pinned(() => ({ + status: 0, + stdout: JSON.stringify([ + { number: 42, url: "https://example/42", isDraft: true, state: "OPEN" }, + ]), + stderr: "", + })), + ); + assert.equal(state.mode, "draft"); + assert.equal(state.pr?.number, 42); +}); + +it("resolveOpenPrState: ready list → ready", () => { + const state = resolveOpenPrState( + pinned(() => ({ + status: 0, + stdout: JSON.stringify([{ number: 7, isDraft: false, state: "OPEN" }]), + stderr: "", + })), + ); + assert.equal(state.mode, "ready"); +}); + +it("resolveOpenPrState: gh crash → unknown (fail closed)", () => { + const state = resolveOpenPrState(pinned(() => ({ status: 2, stdout: "", stderr: "HTTP 401" }))); + assert.equal(state.mode, "unknown"); +}); + +it("resolveOpenPrState: uses --head branch + --repo origin", () => { + let seen = null; + resolveOpenPrState( + pinned((args) => { + seen = args; + return { status: 0, stdout: "[]", stderr: "" }; + }), + ); + assert.isOk(seen.includes("list")); + assert.equal(seen[seen.indexOf("--head") + 1], "feature"); + assert.equal(seen[seen.indexOf("--repo") + 1], "owner/repo"); +}); + +it("resolveOpenPrState: T3CODE_FORK_REPOSITORY overrides origin", () => { + let seen = null; + resolveOpenPrState({ + branch: "feature", + env: { T3CODE_FORK_REPOSITORY: "acme/repo" }, + runGit: () => { + throw new Error("git must not be called when the fork repo is set"); + }, + runGh: (args) => { + seen = args; + return { status: 0, stdout: "[]", stderr: "" }; + }, + }); + assert.equal(seen[seen.indexOf("--repo") + 1], "acme/repo"); +}); + +it("resolveOpenPrState: detached HEAD → unknown (fail closed)", () => { + const state = resolveOpenPrState({ + branch: "HEAD", + repoSlug: "owner/repo", + runGh: () => { + throw new Error("gh must not be called without a branch"); + }, + }); + assert.equal(state.mode, "unknown"); +}); + +it("resolveOpenPrState: no origin repo → unknown (fail closed)", () => { + const state = resolveOpenPrState({ + branch: "feature", + repoSlug: null, + runGh: () => { + throw new Error("gh must not be called without a repo"); + }, + }); + assert.equal(state.mode, "unknown"); +}); + +it("gh policy: blocks pr ready unless AGENT_PR_SHIP", () => { + assert.equal(inspectAgentGhCommand(["pr", "ready"], {}).blocked, true); + assert.equal(inspectAgentGhCommand(["pr", "ready", "12"], {}).blocked, true); + assert.equal(inspectAgentGhCommand(["pr", "ready"], { AGENT_PR_SHIP: "1" }).blocked, false); + assert.equal(inspectAgentGhCommand(["pr", "view"], {}).blocked, false); + assert.equal(inspectAgentGhCommand(["pr", "create", "--draft"], {}).blocked, false); +}); + +it("gh policy: strips -R before matching pr ready", () => { + assert.deepEqual(stripGhGlobalFlags(["-R", "o/r", "pr", "ready"]), ["pr", "ready"]); + assert.equal(inspectAgentGhCommand(["-R", "pingdotgg/t3code", "pr", "ready"], {}).blocked, true); +}); + +it("gh policy: blocks ready_for_review api paths", () => { + assert.equal( + inspectAgentGhCommand(["api", "repos/o/r/pulls/1/ready_for_review", "-X", "POST"], {}).blocked, + true, + ); + assert.equal( + inspectAgentGhCommand( + [ + "api", + "graphql", + "-f", + "query=mutation { markPullRequestReadyForReview(input: {}) { clientMutationId } }", + ], + {}, + ).blocked, + true, + ); +}); + +it("ship-gate cache: match / miss / force", () => { + const sha = "a".repeat(40); + const other = "b".repeat(40); + assert.equal(isShipGateShaCached(sha, { sha }), true); + assert.equal(isShipGateShaCached(sha.toUpperCase(), { sha }), true); + assert.equal(isShipGateShaCached(other, { sha }), false); + assert.equal(isShipGateShaCached(null, { sha }), false); + assert.equal(isShipGateShaCached(sha, null), false); + assert.equal(isShipGateForce({}), false); + assert.equal(isShipGateForce({ AGENT_SHIP_GATE_FORCE: "1" }), true); + assert.equal(isShipGateForce({ AGENT_SHIP_GATE_FORCE: "0" }), false); +}); + +it("ship-gate cache: write + read roundtrip", () => { + const files = new Map(); + const root = "/tmp/agent-ship-gate-test-root"; + const sha = "c".repeat(40); + writeShipGateCache(root, sha, { + mkdirSync: () => {}, + writeFileSync: (file, data) => { + files.set(file, data); + }, + now: () => new Date("2026-08-02T00:00:00.000Z"), + }); + assert.equal(files.size, 1); + const written = [...files.values()][0]; + assert.match(written, new RegExp(sha)); + const cache = readShipGateCache(root, { + readFileSync: (file) => { + if (!files.has(file)) throw new Error("ENOENT"); + return files.get(file); + }, + }); + assert.deepEqual(cache, { sha, validatedAt: "2026-08-02T00:00:00.000Z" }); + assert.equal(isShipGateShaCached(sha, cache), true); +}); diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs new file mode 100644 index 00000000000..78470d37468 --- /dev/null +++ b/scripts/install-git-hooks.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Installs the husky git hooks (runs from the root `prepare` script). +// Exits silently when husky is not installed — e.g. production installs +// without devDependencies. +// +// Git worktrees often miss `core.hooksPath` after husky; re-apply it so +// agent pre-push actually runs in T3/agent worktrees. +// +// Always installs `.tools/bin/gh` — agent policy shim that blocks `gh pr ready` +// (use `pnpm pr:ready`). Put `$REPO/.tools/bin` first on PATH in agent sessions. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import * as NodeURL from "node:url"; + +const root = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); + +const installAgentGhShim = () => { + const toolsBin = NodePath.join(root, ".tools", "bin"); + const agentGh = NodePath.join(root, "scripts", "agent-gh.mjs"); + NodeFS.mkdirSync(toolsBin, { recursive: true }); + const shimPath = NodePath.join(toolsBin, "gh"); + const shim = `#!/usr/bin/env bash +# Installed by scripts/install-git-hooks.mjs — agent gh policy shim. +# Blocks coding-agent \`gh pr ready\`; use \`pnpm pr:ready\` instead. +set -euo pipefail +exec node ${JSON.stringify(agentGh)} "$@" +`; + NodeFS.writeFileSync(shimPath, shim, { encoding: "utf8", mode: 0o755 }); + try { + NodeFS.chmodSync(shimPath, 0o755); + } catch { + // best-effort on platforms without chmod + } +}; + +try { + installAgentGhShim(); +} catch (error) { + console.error(`install-git-hooks: could not install agent gh shim: ${error?.message ?? error}`); +} + +let installHusky; +try { + installHusky = (await import("husky")).default; +} catch (error) { + if (error?.code !== "ERR_MODULE_NOT_FOUND" || !error.message.includes("package 'husky'")) { + throw error; + } + NodeProcess.exit(0); +} + +const installError = installHusky(); +if (installError) { + console.error(installError); + NodeProcess.exit(1); +} + +for (const name of ["pre-commit", "pre-push"]) { + const p = NodePath.join(root, ".husky", name); + if (NodeFS.existsSync(p)) { + try { + NodeFS.chmodSync(p, 0o755); + } catch { + // best-effort + } + } +} + +NodeProcess.exit(0); diff --git a/scripts/lib/agent-env.mjs b/scripts/lib/agent-env.mjs new file mode 100644 index 00000000000..003483ff05f --- /dev/null +++ b/scripts/lib/agent-env.mjs @@ -0,0 +1,26 @@ +/** + * Shared agent-env detection (pre-push, gh shim, pr:ready). + */ +import * as NodeProcess from "node:process"; + +const truthy = (v) => { + if (v == null || v === "") return false; + const s = String(v).toLowerCase(); + return s !== "0" && s !== "false" && s !== "no" && s !== "off"; +}; + +/** @param {NodeJS.ProcessEnv} env */ +export const isCodingAgent = (env = NodeProcess.env) => { + if (truthy(env["SKIP_AGENT_PREPUSH"])) return false; + return ( + truthy(env["GROK_AGENT"]) || + truthy(env["T3_AGENT"]) || + truthy(env["AI_AGENT"]) || + truthy(env["CLAUDECODE"]) || + truthy(env["CLAUDE_CODE"]) || + truthy(env["CURSOR_AGENT"]) || + truthy(env["CURSOR_TRACE_ID"]) || + truthy(env["CODEX_CI"]) || + truthy(env["CODEX_SANDBOX"]) + ); +}; diff --git a/scripts/lib/agent-gh-policy.mjs b/scripts/lib/agent-gh-policy.mjs new file mode 100644 index 00000000000..0eae5a991d8 --- /dev/null +++ b/scripts/lib/agent-gh-policy.mjs @@ -0,0 +1,149 @@ +/** + * Agent policy for `gh`: block undraft / ready-for-review side channels. + * Publish must go through `pnpm pr:ready` (sets AGENT_PR_SHIP=1). + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import * as NodeURL from "node:url"; + +const truthy = (v) => { + if (v == null || v === "") return false; + const s = String(v).toLowerCase(); + return s !== "0" && s !== "false" && s !== "no" && s !== "off"; +}; + +/** @param {NodeJS.ProcessEnv} [env] */ +export const isAgentPrShipAllowed = (env = NodeProcess.env) => truthy(env["AGENT_PR_SHIP"]); + +/** + * Strip leading global `gh` flags so subcommands are at the front. + * @param {string[]} argv + */ +export const stripGhGlobalFlags = (argv) => { + const args = [...argv]; + const skipValue = new Set(["-R", "--repo", "-h", "--hostname", "-p", "--path", "--config-dir"]); + while (args.length > 0) { + const a = args[0]; + if (a === "--") { + args.shift(); + break; + } + if (!a.startsWith("-")) break; + if ( + a.includes("=") && + (a.startsWith("-R=") || a.startsWith("--repo=") || a.startsWith("--hostname=")) + ) { + args.shift(); + continue; + } + if (skipValue.has(a)) { + args.shift(); + if (args.length > 0) args.shift(); + continue; + } + // Unknown global flag with possible value — stop rather than mis-parse. + if (a.startsWith("-") && args.length > 1 && !args[1].startsWith("-") && !a.includes("=")) { + // boolean globals like --help stay; leave them for gh + break; + } + args.shift(); + } + return args; +}; + +/** + * @param {string[]} argv args after the gh binary name + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ blocked: boolean, reason?: string }} + */ +export const inspectAgentGhCommand = (argv, env = NodeProcess.env) => { + if (isAgentPrShipAllowed(env)) return { blocked: false }; + + const args = stripGhGlobalFlags(argv); + if (args.length === 0) return { blocked: false }; + + // gh pr ready [number] + if (args[0] === "pr" && args[1] === "ready") { + return { + blocked: true, + reason: + "agents must not undraft with `gh pr ready` — use `pnpm pr:ready` (runs the ship gate, then marks ready)", + }; + } + + // REST / GraphQL undraft side channels via `gh api` + if (args[0] === "api") { + const joined = args.join(" "); + if (/ready_for_review/i.test(joined) || /markPullRequestReadyForReview/i.test(joined)) { + return { + blocked: true, + reason: "agents must not mark a PR ready via `gh api` — use `pnpm pr:ready`", + }; + } + } + + return { blocked: false }; +}; + +/** + * Resolve the real `gh` binary, skipping this policy shim when it is on PATH. + * @param {{ env?: NodeJS.ProcessEnv, selfPath?: string }} [opts] + * @returns {string | null} + */ +export const findRealGh = (opts = {}) => { + const env = opts.env ?? NodeProcess.env; + if (env["AGENT_GH_REAL"] && NodeFS.existsSync(env["AGENT_GH_REAL"])) { + return env["AGENT_GH_REAL"]; + } + if (env["T3_GITHUB_REAL_GH"] && NodeFS.existsSync(env["T3_GITHUB_REAL_GH"])) { + return env["T3_GITHUB_REAL_GH"]; + } + + const selfPath = opts.selfPath + ? NodePath.resolve(opts.selfPath) + : NodeURL.fileURLToPath(import.meta.url); + + const pathEnv = env["PATH"] ?? ""; + for (const dir of pathEnv.split(NodePath.delimiter).filter(Boolean)) { + for (const name of ["gh", "gh.real"]) { + const candidate = NodePath.join(dir, name); + try { + if (!NodeFS.existsSync(candidate)) continue; + const resolved = NodeFS.realpathSync(candidate); + // Skip our shim (scripts/agent-gh.mjs launched via .tools/bin/gh). + if (resolved === selfPath) continue; + if (resolved.endsWith(`${NodePath.sep}agent-gh.mjs`)) continue; + if (resolved.includes(`${NodePath.sep}.tools${NodePath.sep}bin${NodePath.sep}gh`)) continue; + // Prefer executables; on Windows skip the check. + try { + NodeFS.accessSync(candidate, NodeFS.constants.X_OK); + } catch { + continue; + } + return candidate; + } catch { + // try next + } + } + } + + // Last resort: ask the shell (may return us — caller must detect loops). + const which = NodeChildProcess.spawnSync("bash", ["-lc", "command -v gh"], { + encoding: "utf8", + env, + shell: false, + }); + if (which.status === 0) { + const p = which.stdout.trim(); + if ( + p && + !p.endsWith("agent-gh.mjs") && + !p.includes(`${NodePath.sep}.tools${NodePath.sep}bin${NodePath.sep}gh`) + ) { + return p; + } + } + return null; +}; diff --git a/scripts/lib/agent-pr-state.mjs b/scripts/lib/agent-pr-state.mjs new file mode 100644 index 00000000000..a2b75f81c28 --- /dev/null +++ b/scripts/lib/agent-pr-state.mjs @@ -0,0 +1,172 @@ +/** + * Resolve whether the current branch's open PR should enforce the agent ship gate. + * + * 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) + * + * This fork lives under `patroza/t3code` but `gh` defaults to the upstream + * parent (`pingdotgg/t3code`). A bare `gh pr view` therefore never finds the + * fork's PRs, which would silently disable the gate. `.envrc` sets `GH_REPO` + * to fix that for interactive shells, but the pre-push hook must not depend on + * direnv being loaded — so, matching the rest of the fork tooling + * (scripts/fork-stack.ts), resolve the PR against the fork repo explicitly: + * `T3CODE_FORK_REPOSITORY` when set, else the `origin` remote, passed as + * `gh pr list --head --repo` (an explicit `--repo` overrides GH_REPO). + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeProcess from "node:process"; + +/** + * @param {{ isDraft?: boolean, state?: string } | null | undefined} pr + * @returns {"none" | "draft" | "ready"} + */ +export const classifyPrPayload = (pr) => { + if (pr == null) return "none"; + const state = String(pr.state ?? "").toUpperCase(); + if (state === "CLOSED" || state === "MERGED") return "none"; + if (pr.isDraft === true) return "draft"; + return "ready"; +}; + +/** + * @param {"none" | "draft" | "ready" | "unknown"} mode + * @returns {boolean} + */ +export const shouldRunShipGateOnPush = (mode) => mode === "ready" || mode === "unknown"; + +/** + * Parse `owner/repo` from a GitHub remote URL (ssh, https, or git protocol). + * @param {string | null | undefined} url + * @returns {string | null} + */ +export const parseRepoSlug = (url) => { + if (!url) return null; + const trimmed = String(url).trim(); + const match = trimmed.match( + /(?:git@[^:]+:|ssh:\/\/[^/]+\/|https?:\/\/[^/]+\/|git:\/\/[^/]+\/)([^/]+\/[^/]+?)(?:\.git)?\/?$/, + ); + return match ? match[1] : null; +}; + +/** + * @param {{ + * cwd?: string + * env?: NodeJS.ProcessEnv + * branch?: string + * repoSlug?: string | null + * runGh?: (args: string[], opts: { cwd: string, env: NodeJS.ProcessEnv }) => + * { status: number | null, stdout: string, stderr: string, error?: NodeJS.ErrnoException } + * runGit?: (args: string[], opts: { cwd: string }) => + * { status: number | null, stdout: string, error?: NodeJS.ErrnoException } + * }} [opts] + * @returns {{ mode: "none" | "draft" | "ready" | "unknown", pr: { number?: number, url?: string, isDraft?: boolean, state?: string } | null, detail?: string }} + */ +export const resolveOpenPrState = (opts = {}) => { + const cwd = opts.cwd ?? NodeProcess.cwd(); + const env = opts.env ?? NodeProcess.env; + const runGh = + opts.runGh ?? + ((args, runOpts) => { + const result = NodeChildProcess.spawnSync("gh", args, { + encoding: "utf8", + cwd: runOpts.cwd, + env: runOpts.env, + shell: false, + }); + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + error: result.error, + }; + }); + const runGit = + opts.runGit ?? + ((args, runOpts) => { + const result = NodeChildProcess.spawnSync("git", args, { + encoding: "utf8", + cwd: runOpts.cwd, + shell: false, + }); + return { status: result.status, stdout: result.stdout ?? "", error: result.error }; + }); + + const gitValue = (args) => { + const r = runGit(args, { cwd }); + if (r.error || (r.status !== null && r.status !== 0)) return null; + const v = String(r.stdout ?? "").trim(); + return v || null; + }; + + const branch = opts.branch ?? gitValue(["rev-parse", "--abbrev-ref", "HEAD"]); + const repoSlug = + opts.repoSlug !== undefined + ? opts.repoSlug + : env["T3CODE_FORK_REPOSITORY"]?.trim() || + parseRepoSlug(gitValue(["remote", "get-url", "origin"])); + + // Detached HEAD or unknown origin: we cannot pin the PR to the fork repo. + // Fail closed so the gate still runs rather than silently skipping. + if (!branch || branch === "HEAD" || !repoSlug) { + return { + mode: "unknown", + pr: null, + detail: + !branch || branch === "HEAD" ? "no branch (detached HEAD?)" : "cannot resolve origin repo", + }; + } + + const result = runGh( + [ + "pr", + "list", + "--head", + branch, + "--repo", + repoSlug, + "--state", + "open", + "--json", + "number,url,isDraft,state", + ], + { cwd, env }, + ); + + if (result.error && result.error.code === "ENOENT") { + return { mode: "unknown", pr: null, detail: "gh not found on PATH" }; + } + + const status = result.status === null ? 1 : result.status; + const stderr = String(result.stderr ?? ""); + const stdout = String(result.stdout ?? "").trim(); + + // gh error (auth / network / bad repo): fail closed, run the gate. + if (status !== 0) { + return { + mode: "unknown", + pr: null, + detail: stderr.trim() || stdout || `gh pr list exited ${status}`, + }; + } + + if (!stdout) { + return { mode: "none", pr: null, detail: "no open PR for branch" }; + } + + try { + const list = JSON.parse(stdout); + const pr = Array.isArray(list) ? list[0] : list; + if (!pr) return { mode: "none", pr: null, detail: "no open PR for branch" }; + const mode = classifyPrPayload(pr); + return { mode, pr, detail: mode === "none" ? `PR ${pr.number} is ${pr.state}` : undefined }; + } catch (error) { + return { + mode: "unknown", + pr: null, + detail: `failed to parse gh pr list JSON: ${error?.message ?? error}`, + }; + } +}; diff --git a/scripts/lib/agent-ship-gate-cache.mjs b/scripts/lib/agent-ship-gate-cache.mjs new file mode 100644 index 00000000000..9d3326db497 --- /dev/null +++ b/scripts/lib/agent-ship-gate-cache.mjs @@ -0,0 +1,102 @@ +/** + * Persist the last HEAD SHA that passed the agent ship gate. + * pre-push and `pnpm pr:ready` share this so the same commit is never double-paid. + * + * Path: `.run/agent-ship-gate.json` (under gitignored `.run/`). + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; + +export const SHIP_GATE_CACHE_REL = NodePath.join(".run", "agent-ship-gate.json"); + +/** @param {string} [root] */ +export const shipGateCachePath = (root = NodeProcess.cwd()) => + NodePath.join(root, SHIP_GATE_CACHE_REL); + +/** + * @param {string} [root] + * @param {{ runGit?: (args: string[], opts: { cwd: string }) => { status: number | null, stdout: string } }} [opts] + * @returns {string | null} full SHA or null + */ +export const readHeadSha = (root = NodeProcess.cwd(), opts = {}) => { + const runGit = + opts.runGit ?? + ((args, runOpts) => { + const result = NodeChildProcess.spawnSync("git", args, { + encoding: "utf8", + cwd: runOpts.cwd, + shell: false, + }); + return { status: result.status, stdout: result.stdout ?? "" }; + }); + const result = runGit(["rev-parse", "HEAD"], { cwd: root }); + if (result.status !== 0) return null; + const sha = String(result.stdout).trim(); + return /^[0-9a-f]{40}$/i.test(sha) ? sha.toLowerCase() : null; +}; + +/** + * @param {string} [root] + * @param {{ readFileSync?: typeof NodeFS.readFileSync }} [opts] + * @returns {{ sha: string, validatedAt?: string } | null} + */ +export const readShipGateCache = (root = NodeProcess.cwd(), opts = {}) => { + const readFileSync = opts.readFileSync ?? NodeFS.readFileSync; + const file = shipGateCachePath(root); + try { + const raw = readFileSync(file, "utf8"); + const parsed = JSON.parse(raw); + const sha = typeof parsed?.sha === "string" ? parsed.sha.trim().toLowerCase() : ""; + if (!/^[0-9a-f]{40}$/i.test(sha)) return null; + return { + sha, + validatedAt: typeof parsed.validatedAt === "string" ? parsed.validatedAt : undefined, + }; + } catch { + return null; + } +}; + +/** + * @param {string} root + * @param {string} sha + * @param {{ writeFileSync?: typeof NodeFS.writeFileSync, mkdirSync?: typeof NodeFS.mkdirSync, now?: () => Date }} [opts] + */ +export const writeShipGateCache = (root, sha, opts = {}) => { + const writeFileSync = opts.writeFileSync ?? NodeFS.writeFileSync; + const mkdirSync = opts.mkdirSync ?? NodeFS.mkdirSync; + const now = opts.now ?? (() => new Date()); + const normalized = String(sha).trim().toLowerCase(); + if (!/^[0-9a-f]{40}$/i.test(normalized)) { + throw new Error(`writeShipGateCache: invalid sha ${sha}`); + } + const file = shipGateCachePath(root); + mkdirSync(NodePath.dirname(file), { recursive: true }); + writeFileSync( + file, + `${JSON.stringify({ sha: normalized, validatedAt: now().toISOString() }, null, 2)}\n`, + "utf8", + ); +}; + +/** + * @param {string | null | undefined} headSha + * @param {{ sha: string } | null | undefined} cache + */ +export const isShipGateShaCached = (headSha, cache) => { + if (!headSha || !cache?.sha) return false; + return headSha.toLowerCase() === cache.sha.toLowerCase(); +}; + +/** + * Force re-run even when SHA is cached. + * @param {NodeJS.ProcessEnv} [env] + */ +export const isShipGateForce = (env = NodeProcess.env) => { + const v = env["AGENT_SHIP_GATE_FORCE"]; + if (v == null || v === "") return false; + const s = String(v).toLowerCase(); + return s !== "0" && s !== "false" && s !== "no" && s !== "off"; +}; diff --git a/vite.config.ts b/vite.config.ts index 45567805b37..7651ba1af0a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,11 +21,15 @@ export default defineConfig({ testTimeout: 60_000, }, staged: { - // Formatter only for now — no lint or typecheck on commit. + // Commit runs format + lint (keep in sync with lint-staged.config.js). + // Heavier typecheck + tests stay in the agent ship gate (pre-push on ready + // PRs / `pnpm pr:ready`). // `--no-error-on-unmatched-pattern`: a commit whose staged files are all // unformattable (e.g. only *.nix) leaves `vp fmt` with no targets, which // otherwise fails the whole pre-commit. Treat "nothing to format" as a no-op. "*": "vp fmt --no-error-on-unmatched-pattern", + // Lint (with autofix) only the code files oxlint understands. + "*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": "vp lint --fix", }, fmt: { ignorePatterns: [