diff --git a/AGENTS.md b/AGENTS.md index 8d5bc1b6849e..37fcd931d04d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,207 +2,74 @@ ## Downstream fork branches and pull requests -Branch from `fork/dev`, open every PR against `fork/dev`, and let it merge by squash. That is the -whole branching model. See -[docs/stable-dev-release-branch-handover.md](./docs/stable-dev-release-branch-handover.md). - -- Before the documented one-time cutover, implementation PRs continue to target `main`. -- After cutover, `main` is an upstream mirror. Never merge downstream fork work into it. -- Update `main` only by fast-forwarding it to the upstream tip, never via GitHub's **Sync fork** - button, a PR into `main`, or a casual force-push. The stack-rewrite workflow is - **`disabled_manually` — leave it disabled.** Do not enable or dispatch it. Local restacks that - must move protected tips use the repository-scoped `FORK_STACK_DEPLOY_KEY` (or an allowed bypass - actor) only for that intentional rewrite; agents must never print or reuse that credential. -- **`fork/base`** sits on upstream `main` and holds **only** fork repository plumbing (GitHub-hosted - Fork CI, Blacksmith-free `ci.yml` runners, `docs/fork-base.md`). No Tim, candidates, or product. - Permanent draft PR against `main`. Restacks rebuild base first, then Tim → candidates → changes. -- `fork/tim` contains only selected Tim Smart integrations above **`fork/base`**. `fork/candidates` - contains selected open upstream PRs that we run before upstream accepts them, one provenance - commit per source PR, above Tim. The permanent `fork/changes` PR is based on `fork/candidates`, - contains only our downstream layer, remains open, and is the GitHub/T3 default branch. -- **Integration overlays are retired.** `integrationOverlays` is empty, the four registered overlay - PRs are closed, and their content lives in `fork/dev`. Do not create one, and do not target - `fork/discord`, `fork/vscode`, `fork/identity` or the desktop deep-links branch. Client-owned work - is an ordinary PR against `fork/dev`; ownership follows changed paths. -- **Closed permanent draft PRs (overlays / managed stack PRs):** do **not** open a replacement PR - as the first reaction. (1) Fix the branch tip if needed (rebase onto the intended base, force- - with-lease). (2) **`gh pr reopen `** the **same** PR number and restore draft + **`OVERLAY`** - (for overlays). (3) Only if GitHub refuses reopen, create a new draft PR, apply **`OVERLAY`**, and - update `.github/pr-stack.json` `integrationOverlays[].number` in the same change. Never leave the - manifest pointing at a closed PR. -- **Fixing `fork/changes` or an overlay tip:** prefer **amending / rewriting the commit that - introduced the bug** (or folding into the existing product/reapply commit), then force-with-lease - the layer tip. Do **not** stack permanent tip-only `fix(…)` / `style(…)` recovery commits when the - tip is still operator-owned stack surface and history rewrite is allowed. New tip commits are OK - for ordinary **feature** work that lands via PR merge into the layer. -- **Branch from `fork/dev` and open every PR against `fork/dev`.** Discord, VS Code, identity and - desktop work included: the integration overlays were drained into `fork/dev` and deregistered, so - there is no overlay branch to target. Ownership is inferred from changed paths, not from the base - branch. -- **Never open implementation PRs against `main`, `fork/changes` or `fork/integration`.** `main` is - the upstream mirror; the other two are frozen and superseded by `fork/dev`. All three produce a - huge unrelated diff, and `fork/changes` is rebased, which silently invalidates a PR based on it. - See [docs/stable-dev-release-branch-handover.md](./docs/stable-dev-release-branch-handover.md). -- When a PR is CONFLICTING or behind, rebase it onto `fork/dev` yourself and force-with-lease. - There is no stack automation to run: upstream is merged straight into `fork/dev`, so a PR is - only ever behind ordinary commits. +```text +pingdotgg/t3code:main + └── main exact upstream mirror, fast-forward only + └── fork/dev product: PR target, squash-merge, release source +``` + +- **Change PRs:** branch from `fork/dev`, open every implementation PR against `fork/dev`, and + squash-merge it. Never open implementation PRs against `main`. +- **Catching a published PR up:** once the PR is no longer draft, rebase onto latest `fork/dev` or + merge latest `fork/dev` into the PR branch if it is behind or conflicting. Either is fine — pick + one. Draft PRs skip this. After publish, watch required checks until they are green. +- **Updating from upstream:** fast-forward `main` to `upstream/main`, then classic-merge `main` into + `fork/dev`. Never merge downstream work into `main`, and never use GitHub's **Sync fork** button. + When the merge hits shared product paths, do a 3-way merge — never a blind whole-file + `ours`/`theirs`. - Independent features use parallel PRs based on `fork/dev`. Chain PRs only when one change genuinely depends on another, and merge that chain bottom-up. -- Treat external forks and open upstream PRs as selective import sources. Tim Smart imports land as - one reviewed commit per source PR on `fork/tim`; selected unmerged upstream work lands as one - reviewed commit per source PR on `fork/candidates`; our adaptations land separately on - `fork/changes`. Cherry-pick only wanted commits, explicitly document imported, adapted, and - excluded pieces, and never merge a source branch wholesale. -- During every Tim/candidates import or restack, inspect all new and changed persistence migrations. - Preserve true upstream migrations unchanged in `Migrations.ts`; rewrite every Tim, candidate, - overlay, or downstream migration into the fork-local manifest and ledger in `ForkMigrations.ts`. - Never share a migration ledger between upstream and fork histories. Full rules: - [docs/fork-stack.md](./docs/fork-stack.md) ("Migration namespaces during provenance imports"). -- Run and deploy from `fork/integration`, never from a temporary feature or import branch. - All features land in `fork/dev`, including upstreamable work. To send something upstream, open a - PR from a branch cut against upstream `main` in the usual GitHub way. Use `adopt` only for work that began upstream-first, and `demote` to close an - upstream projection without removing the canonical downstream implementation. - -### Automatic integration and deployment - -- Opening or updating a PR runs CI but does not deploy. -- **Layer status = Fork CI only.** Permanent `fork/*` / overlay draft PRs must look green or red - solely from **Check**, **Test**, **Mobile Native Static Analysis**, and **Release Smoke** - (`.github/workflows/fork-ci.yml`). **Compose fork integration** is an integration rebuild, not a - product-layer quality gate — do **not** add it (or any compose/rebase job) to required status - checks for those branches. -- **Fast path (day-to-day):** `.github/workflows/compose-integration.yml` (**Compose fork - integration**) runs **only** when: - - a PR is **merged** into **`fork/changes`**, or - - a PR is **merged** into a **registered overlay base** (desktop / discord / vscode / identity), - or - - it is started with **`workflow_dispatch`** (manual compose after a local tip rewrite). - It does **not** run on branch **pushes** (force-push rebases, deploy-key updates, compose's own - overlay force-with-lease). That avoids check noise on permanent draft PRs and rebase storms. - On merge it auto-rebases every registered overlay onto current `fork/changes` (no-op when - already based; force-with-lease on clean rebases), composes those tips into `fork/integration`, - and dispatches **Fork CI** on the composed tip. It does **not** rewrite main/tim/candidates or - ordinary feature PRs. Real overlay rebase conflicts fail the job with branch + paths — fix that - overlay, then re-run compose (`workflow_dispatch` or merge again). When adding an overlay to - `.github/pr-stack.json`, also add its branch to the `on.pull_request` base list in - `compose-integration.yml` (and to `fork-ci.yml` PR bases). -- **Slow path (upstream / Tim / candidates):** **manual / local only.** Run - merge `upstream/main` into `fork/dev` directly; `main` is then fast-forwarded to the upstream tip. - There is no restack workflow any more. Pushes to `main` / `fork/tim` / `fork/candidates` must not auto-restack - or auto-compose. Local restacks mirror `pingdotgg/t3code:main`, rebuild provenance layers with - stop-the-line green gates, rebase overlays, then compose integration via - `workflow_dispatch` / local compose scripts. Deploy key (if used) is only for intentional - protected-branch force-with-lease; agents must never print or reuse it. -- Fork checks live in `.github/workflows/fork-ci.yml` and run for PRs targeting product bases (or by - explicit `workflow_dispatch` on a branch tip). Permanent stack tips: - - `fork/candidates` / `fork/changes` / overlays: PR base list in `fork-ci.yml` - - `fork/tim` (PR #1 bases on `main`): green via **`workflow_dispatch --ref fork/tim`** so we do - not attach Fork CI to every ordinary PR into `main` - - After a restack that rewrites tips, re-dispatch Fork CI on **tim** and **candidates** when you - need those permanent draft PRs green again + PR from a branch cut against upstream `main` in the usual GitHub way. +- Opening or updating a PR runs Fork CI (`.github/workflows/fork-ci.yml`) but does not deploy. + Releases come from green `fork/dev` SHAs. Machine topology and deployment implementation belong + in a separate private operations repository, not this one. - The inherited upstream **`.github/workflows/ci.yml` (Blacksmith runners)** and `deploy-relay.yml` - workflows are **`disabled_manually` at repository level**. This fork has no Blacksmith runners — - if CI is left enabled, Tim/candidates jobs queue forever on `blacksmith-*-ubuntu-2404` / - `blacksmith-*-macos-*` labels. **Do not re-enable** upstream CI or relay deploy for fork releases. - After any accident re-enable: `gh workflow disable CI --repo `. -- Successful `fork/integration` CI classifies the complete tree diff from the previous approved - integration tree. Runtime-affecting changes hand the exact tested SHA to the private operations - repository; tests, documentation, agent metadata, and GitHub-only metadata do not deploy. -- Machine topology and deployment implementation belong in a separate private operations repository, - not this repository. -- **Lockfile after stack rebase / conflict resolution (required):** never leave - `pnpm-lock.yaml` mismatched with any `package.json` after a manual or automated layer rewrite. - Taking `--ours` on the lockfile during conflicts is **not** finished work when `package.json` - (or workspace package manifests) still declare different deps. Before treating the stack or a - recovery PR as done: - 1. On the rewritten tip (usually `fork/changes`), run `CI= pnpm install --no-frozen-lockfile` - (or `vp install` with frozen lockfile disabled) until the lockfile matches. - 2. Commit the updated `pnpm-lock.yaml` on a PR targeting `fork/dev` (or include it in the - recovery commit that lands the rewrite). - 3. Recompose `fork/integration` if the tip already moved, then re-dispatch Fork CI. - 4. Confirm install would succeed under CI: frozen lockfile is **on** in Fork CI; failures look - like `ERR_PNPM_OUTDATED_LOCKFILE` / "specifiers in the lockfile don't match package.json". - Prefer regenerating the lockfile over repeatedly choosing ours/theirs on `pnpm-lock.yaml` during - multi-commit rebases of `fork/changes`. -- **Per-layer full CI gate after stack rebase (required — stop the line):** when rebasing, - replaying, or rewriting the stack, **every layer must pass the full local CI gate before you - touch the next layer**. Do **not** rebase, compose, or push a child layer onto a parent that is - still red. Do **not** “finish the stack rewrite first and green it later.” - - Order: `fork/base` → `fork/tim` → `fork/candidates` → `fork/changes` → each integration overlay - → compose `fork/integration` last. - - On **each** layer tip after it is rewritten: install/lock consistent, then run the **full** - local Fork CI gate (not only `vp check`) — see **Per-layer stack CI (stop the line)** under - Task Completion Requirements and [docs/fork-stack.md](./docs/fork-stack.md) - (“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. - - Feature PRs: rebase onto `fork/dev`, 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. - During rebase, `theirs` = commit being replayed, `ours` = new base. Documented in - [docs/fork-stack.md](./docs/fork-stack.md) ("Conflict resolutions"). -- **Product conflicts (shared UI / app code):** never blind whole-file `ours`/`theirs` on shared - product paths. 3-way merge or re-apply the feature commit; run a pre/post parity check so helpers - and tests cannot survive while JSX/wiring is dropped (see #154 remote Open in VS Code button). - Full rules: [docs/fork-stack.md](./docs/fork-stack.md) ("Product conflicts"). -- **No tip-only product `fix(stack)` recovery:** whole-file stack resolves that drop VCS/UI must be - fixed inside the related provenance/feature commit (or one product-named commit during rewrite), - not as permanent tip patches. Same rule for CI format/typecheck recovery on **`fork/changes` and - overlay tips**: amend/rewrite the offending commit when you have stack push bypass; do not leave - a forever-forward `style(docs):` / `fix(stack):` tip. - See [docs/fork-stack.md](./docs/fork-stack.md) (“Commit-green during stack rewrite”, “Permanent - draft PRs”). -- **Fork product changes need existence/behavior tests:** every user-visible or behavioral fork - change must land with a test that fails if the surface disappears (pure helpers alone are not - enough). Prefer pure gates + `aria-label`/`data-testid` existence, or markers in + workflows are **`disabled_manually` at repository level**. This fork has no Blacksmith runners. + **Do not re-enable** them. After an accidental re-enable: `gh workflow disable CI --repo `. +- Persistence migrations: keep true upstream migrations in `Migrations.ts`; rewrite fork-local + migrations into `ForkMigrations.ts`. Never share a migration ledger between upstream and fork + histories. +- Fork product / UI changes must land with a test that fails if the surface disappears (pure + helpers alone are not enough). Prefer `aria-label`/`data-testid` existence, or markers in `apps/web/src/forkSurfaceExistence.test.ts` for chrome. -- **Integration compose lockfiles:** overlay lock commits diverge by design. Compose skips - lockfile-only commits, defers lock-only conflicts, and regenerates one integration - `pnpm-lock.yaml` at the end. Never push a partial `fork/integration` after a lock conflict. - Compose seeds `node_modules` via `cp -a --reflink=auto` from a warm tree into a **home-side** - work dir (`~/.t3/compose-work`, not tmpfs `/tmp`) before install. See - [docs/fork-stack.md](./docs/fork-stack.md) ("Integration overlay compose and lockfiles"). +- After a merge that touches dependencies, never leave `pnpm-lock.yaml` mismatched with + `package.json`. Regenerate the lockfile (`CI= pnpm install --no-frozen-lockfile`) rather than + taking `--ours`/`--theirs` on it. ## Pull requests (required handoff) When implementation work for a user request is done (code, docs, config — not pure Q&A): 1. **Commit** the changes on a feature branch cut from `fork/dev`. -2. **Open or update a PR against `fork/dev`** before handing off — for every kind of work, including - Discord, VS Code, identity and desktop. Never `main`, `fork/changes` or `fork/integration`. +2. **Open or update a PR against `fork/dev`** before handing off. Never target `main`. 3. **Let the agent ship gate own validation** before saying “updated the PR” or finishing (see _Task Completion Requirements → Agent ship gate_): - - **Every agent push is gated.** Draft / no-PR pays the **static** half (`vp check` + - `vpr typecheck`). Ready PRs and publish pay the **full** gate (+ `vp run test`). Do **not** - hand-run those as routine validation — the hook owns them and caches the SHA (focused, scoped - proof while iterating is still fine). - - **Publishing always runs the full ship gate** (`vp check` → `vpr typecheck` → `vp run test`), - and only a failing gate stops it. `pnpm pr:ready` is the explicit path; raw `gh pr ready` is not - refused — the `.tools/bin/gh` shim runs the same gate first and then lets the command through. - Either way the checks run, so there is no way to publish around them and nothing to remember. - The husky `pre-push` hook enforces the gate on every agent push and fails closed when PR state - can’t be resolved. - - **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. - - Rebase onto `fork/dev` and force-with-lease; the ensuing push runs the appropriate gate scope. + - **Draft / no-PR pushes** run `vp check` on files changed against `fork/dev`, plus workspace + `vpr typecheck`. Commits already pay lint-staged (fmt + lint of staged files). Do **not** + hand-run the unit suite while drafting. + - **Publishing** (`pnpm pr:ready`) requires HEAD to contain latest `fork/dev` (rebase or merge + — your choice), then the **full** ship gate (`vp check` → `vpr typecheck` → `vp run test`). + Only a failing gate stops it. Raw `gh pr ready` is not refused — the `.tools/bin/gh` shim + runs the same gate first. The husky `pre-push` hook fails closed when PR state can’t be + resolved. + - **Published (ready) PRs** pay the full ship gate on every agent push. After publish, **watch + the PR until required checks are green** (`gh pr checks` / `gh pr view`). Fix real failures + and re-push; dismiss false positives with a written reason. Stay quiet when nothing is new. + Stop when the bots are green on the latest commit. - 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). + - `baseRefName` must be `fork/dev`. After publish, `mergeable` should be `MERGEABLE` (CI may + still be `UNSTABLE` while checks run — that is what you watch to green). 4. **Before pushing follow-ups**, verify PR state with `gh pr view` (or equivalent): - If the PR is **open** → update that branch and push; the - gate re-runs for that HEAD (static if draft, full if ready). + gate re-runs for that HEAD (changed-file if draft, full if ready). - 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). + Start a new branch, re-apply unmerged work, and open a **new PR** against `fork/dev`. 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) 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. + gate and whose required checks are not green — publish through `pnpm pr:ready` first, then + watch CI. GitHub required checks on `fork/dev` are the backstop; the ship gate is what runs + first. ## Discord-originated commits (REQUIRED) @@ -237,12 +104,11 @@ Prefer the thread starter’s Discord id/display name from turn context. Do not ## Task Completion Requirements -### Agent ship gate (static on every push, full on ready / publish) +### Agent ship gate (changed files on draft, full 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. **No agent push is free:** draft / no-PR still pays the static half; ready and -`pnpm pr:ready` pay the full gate. +Publish validation is **automated**. Agents do **not** hand-run the workspace +linter/typechecker/tests as a routine pre-handoff ritual — the ship gate runs them at the right +moment, and caches a passing full-gate SHA. **Mechanism** (`scripts/agent-pre-push.mjs`, `scripts/agent-pr-ready.mjs`, `scripts/lib/*.mjs`): @@ -250,61 +116,60 @@ result. **No agent push is free:** draft / no-PR still pays the static half; rea 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) — **every agent push** - 2. **`vpr typecheck`** — workspace TypeScript — **every agent push** - 3. **`vp run test`** — unit tests — **ready PR / publish only** - 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` (static vs complete - stages), so a draft push and the publish step never double-run the work already paid for that - commit. Force with `AGENT_SHIP_GATE_FORCE=1`. +- When it runs: + 1. **Draft / no-PR:** `vp check` on files changed against `fork/dev` (fmt + lint), then + workspace **`vpr typecheck`**. + 2. **Ready / publish:** workspace **`vp check`** → **`vpr typecheck`** → **`vp run test`**. + Cargo, mobile native, desktop packaging, and Release Smoke stay **CI-only** — do not hand-run + them for ordinary PR handoff. +- The **full** gate **skips when the HEAD SHA is already cached** in `.run/agent-ship-gate.json`. + Force with `AGENT_SHIP_GATE_FORCE=1`. **When it runs:** -| Branch PR state | Agent push | -| ---------------------- | ------------------------------------------- | -| No open PR | **static** (`vp check` + `vpr typecheck`) | -| **Draft** PR | **static** (unit tests deferred to publish) | -| **Ready** PR | **full** ship gate on every push | -| PR state can’t resolve | **full** ship gate (**fail closed**) | - -This is identical for `fork/dev` and dependent PRs — the gate keys off the PR’s **ready state**, not -its base. Managed-PR draft-lock green is **not** the gate. - -**Publish path:** `pnpm pr:ready` — runs the **full** ship gate, then marks the open draft PR ready. -Raw `gh pr ready` (and the ready-for-review APIs) reach the same place: the `.tools/bin/gh` policy -shim runs the ship gate first and passes the command through when it is green, so publishing is -gated rather than forbidden and a red gate is the only thing that stops it. `AGENT_PR_SHIP=1` marks -a gate already passed — `pr:ready` sets it for its own undraft call so the gate runs once, not twice. -`.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`. +| Branch PR state | Agent push | +| ---------------------- | --------------------------------------------- | +| No open PR | **changed-file** `vp check` + `vpr typecheck` | +| **Draft** PR | **changed-file** `vp check` + `vpr typecheck` | +| **Ready** PR | **full** ship gate on every push | +| PR state can’t resolve | **full** ship gate (**fail closed**) | + +The gate keys off the PR’s **ready state**, not its base. + +**Publish path:** `pnpm pr:ready` — HEAD must contain latest `fork/dev`, then the **full** ship +gate, then the open draft is marked ready. After that, **watch GitHub checks until they are +green**. Raw `gh pr ready` (and the ready-for-review APIs) reach the same place: the `.tools/bin/gh` +policy shim runs the ship gate first and passes the command through when it is green, so publishing +is gated rather than forbidden and a red gate is the only thing that stops it. `AGENT_PR_SHIP=1` +marks a gate already passed — `pr:ready` sets it for its own undraft call so the gate runs once, +not twice. `.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 while it is a - draft — each push pays the **static** gate only (cached per HEAD SHA). -- **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 full 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 - JS validation and caches the passing SHA. + draft — each push pays **changed-file** `vp check` + `vpr typecheck`. +- **Publish when the work is done — do not leave a finished PR in draft.** Catch up to latest + `fork/dev` if needed, then `pnpm pr:ready`. That is the immediate next action, before handoff + notes, so full CI can start. Draft is only for work-in-progress. +- **After publish, monitor the PR to green.** Poll checks and comments newer than the last push. + Verify each bot finding against the source, fix real ones, dismiss false positives with a written + reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. - **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. + surface is dropped (not only pure helpers) — see `apps/web/src/forkSurfaceExistence.test.ts`. + 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 stays in the ship gate; unit tests on ready / -publish). If the hook rewrites files, stage those rewrites, commit, and push again. +staged code files. Typecheck and unit tests stay in the full ship gate (ready / publish). If the +hook rewrites files, stage those rewrites, commit, and push again. **Explicitly forbidden:** @@ -313,67 +178,15 @@ publish). If the hook rewrites files, stage those rewrites, commit, and push aga - 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; draft pushes pay static). +- Treating “CI will catch it” as a substitute for publishing through the gate. If a PR’s checks + panel shows **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; draft pushes pay changed-file + check + typecheck). +- Publishing a PR that is behind `fork/dev`, or walking away from a published PR before its required + checks are green. - 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`): - -1. Finish **only the current layer** (rebase/replay complete, lockfile consistent, conflicts - resolved and recorded in `conflictResolutions` when applicable). -2. On that layer’s tip, run the **full local CI gate** — every step you can run on the host that - Fork CI runs for a green PR tip: - - `vp check` - - `ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run -r --cache --log labeled typecheck` - - `vp run --cache build:desktop` and preload verify (same as Fork CI **Check**) - - `ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run test` (Fork CI **Test** — **required on every stack - layer**, not optional) - - On macOS hosts when mobile/desktop shell is in play: `vp run lint:mobile` and the Open With - test from Fork CI **Mobile Native Static Analysis** when those paths are available - - `node scripts/release-smoke.ts` when release/workflow packaging paths may have changed -3. **All of those steps must pass on the current layer.** Fix failures on **this** layer (commit + - force-with-lease push the layer branch if it is shared). Do not paper over with a fix only on a - child layer. -4. **Only after the current layer is fully green**, rebase/replay/compose the **next** layer onto - it. Repeat from step 1. - -**Layer order (never skip ahead):** - -```text -main (upstream mirror — do not hand-edit product fixes) - → fork/base (fork-only CI / repo plumbing) - → fork/tim - → fork/candidates - → fork/changes - → each integration overlay (in manifest order) onto fork/changes - → fork/integration (compose last; full CI on the composed tip) -``` - -**Hard rules:** - -- **One red layer blocks the entire rest of the rewrite.** Stop. Fix. Re-run the full gate on that - layer. Then continue. -- **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** 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”). - ### Client-visible verification After frontend feature development or any user-visible frontend behavior change, the primary agent diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..0a6139e39f2d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,8 @@ Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) ## Working on T3 Code Everything below is for maintainers. Setup lives in the [root README](../README.md); -policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../AGENTS.md). +policy in [CONTRIBUTING.md](../CONTRIBUTING.md); fork branch model and agent rules in +[AGENTS.md](../AGENTS.md). - [Architecture overview](./internals/overview.md) - [Workspace layout](./internals/workspace-layout.md) diff --git a/docs/fork-base.md b/docs/fork-base.md deleted file mode 100644 index 2152f6eb5047..000000000000 --- a/docs/fork-base.md +++ /dev/null @@ -1,35 +0,0 @@ -# fork/base - -`fork/base` sits between the upstream mirror (`main`) and provenance layers: - -```text -main → fork/base → fork/tim → fork/candidates → fork/changes → overlays → integration -``` - -## What belongs here - -**Only** repository adaptations for this fork — not Tim imports, not candidates, not product UI. - -| Area | Examples | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workflows | `fork-ci.yml`, `fork-release.yml`, `managed-pr-draft-lock.yml`, Blacksmith-free `ci.yml`, fork EAS/release tweaks; drop upstream `pr-vouch` / `pr-size` when unused | -| Stack manifests | `.github/pr-stack.json` (managed-PR allowlist only), `upstream-candidates.json` | -| Stack tools | `scripts/classify-deployment-diff.sh` | -| Agent / ops docs | `AGENTS.md`, `docs/stable-dev-release-branch-handover.md`, this file | -| Root scripts | `package.json` `fork:*` entries | - -## What does **not** belong here - -- Tim Smart import commits (`feat(tim): …`) -- Upstream candidate provenance (`feat: import …`) -- Product features, mobile/web UI, contracts RPC behavior - -Those stay on `fork/tim` / `fork/candidates` / `fork/changes` respectively. - -## Restack - -1. Update `main` from upstream. -2. Replay / rebuild **this** layer onto the new `main` tip (keep the fork infra tree). -3. Rebuild `fork/tim` → `fork/candidates` → `fork/changes` → overlays → compose. - -Keep repository workflow **CI** (upstream Blacksmith) **disabled_manually**. Layer green is **Fork CI**. diff --git a/docs/fork-stack.md b/docs/fork-stack.md deleted file mode 100644 index 3c968caf857e..000000000000 --- a/docs/fork-stack.md +++ /dev/null @@ -1,44 +0,0 @@ -# Fork branch topology - -How this fork's branches relate. The operating model — contributing, releasing, syncing upstream — -is [stable-dev-release-branch-handover.md](./stable-dev-release-branch-handover.md). - -```text -pingdotgg/t3code:main - └── main exact upstream mirror, fast-forward only - └── fork/dev canonical product: contributor target and release source -``` - -| Branch | Rewritten | Role | -| ------------------ | -------------- | ---------------------------------------------------------------------------- | -| `main` | mirror-managed | Exact copy of `pingdotgg/t3code:main`. Never receives downstream work. | -| `fork/dev` | **never** | The product. Every PR targets it; every release comes from it. | -| `fork/base` | was | Fork-only CI plumbing. Permanent draft PR #255 against `main`. | -| `fork/tim` | was | Selected Tim Smart imports. Permanent draft PR #1. | -| `fork/candidates` | was | Selected open upstream PRs. Permanent draft PR #27. | -| `fork/changes` | was | Superseded by `fork/dev`. Frozen; delete once its remaining PRs are drained. | -| `fork/integration` | was | Superseded by `fork/dev`. Frozen. | - -## Upstream - -`upstream/main` is merged straight into `fork/dev`, and `main` is fast-forwarded to the same tip. -Because upstream is append-only, `fork/dev` carries its real commits, so the branch page's "commits -behind" is accurate. See -[Synchronizing Upstream](./stable-dev-release-branch-handover.md#synchronizing-upstream-into-forkdev). - -## The provenance branches - -`fork/base`, `fork/tim` and `fork/candidates` record which upstream and Tim work was selected before -upstream accepted it. Their content is already in `fork/dev`, and **they are no longer rebuilt when -upstream moves** — the tooling that rebased them has been removed. - -Their PRs stay **draft**, enforced by `managed-pr-draft-lock.yml`. That matters most for #255: it -targets `main`, so merging it would push fork CI plumbing into the upstream mirror. - -Do not merge them. Do not target them with new work. - -## Integration overlays - -Retired. The four registered overlays were drained into `fork/dev`, their PRs closed, and -`integrationOverlays` emptied. `.github/pr-stack.json` survives only as the allowlist for the draft -lock. There is no overlay to create, target, or compose. diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index 55ffe0d06cd1..92949cde3e28 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -61,16 +61,16 @@ 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 pr:ready`: Agent publish path — requires HEAD to contain latest `fork/dev`, runs the full + 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 runs the same gate first + (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: static gate — `vp check` + typecheck — - on draft / no PR; full ship gate including unit tests on ready PRs / publish; staged SHA cache in - `.run/agent-ship-gate.json`). + `pre-push` runs `scripts/agent-pre-push.mjs` (agents only: changed-file `vp check` + + `vpr typecheck` on draft / no PR; full ship gate including unit tests on ready PRs / publish; + full-gate 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/docs/operations/ci.md b/docs/operations/ci.md index e7af6f291130..239baff3e2d8 100644 --- a/docs/operations/ci.md +++ b/docs/operations/ci.md @@ -1,8 +1,8 @@ # CI quality gates -- `.github/workflows/fork-ci.yml` runs the fork quality gates for pull requests and for exact - `fork/integration` SHAs dispatched by the stack workflow. The inherited upstream `ci.yml` workflow - is disabled so updates to the exact `main` mirror do not duplicate those checks. +- `.github/workflows/fork-ci.yml` runs the fork quality gates for pull requests targeting + `fork/dev` and for pushes to `fork/dev`. The inherited upstream `ci.yml` workflow is disabled so + updates to the `main` mirror do not duplicate those checks. - `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. - The release workflow auto-enables signing only when platform credentials are present. macOS passkey builds additionally require `APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. Without the core signing credentials, it still releases unsigned artifacts. - See [Release Checklist](./release.md) for the full release/signing setup checklist. diff --git a/docs/stable-dev-release-branch-handover.md b/docs/stable-dev-release-branch-handover.md deleted file mode 100644 index 22055efbd468..000000000000 --- a/docs/stable-dev-release-branch-handover.md +++ /dev/null @@ -1,560 +0,0 @@ -# Stable Development and Release Branch Handover - -## Status - -**Adopted and live since 2026-08-06.** `fork/dev` is the default branch, the contributor target and -the release source. This document is the record of the model and of what the cutover surfaced, not a -proposal. - -What is in place: - -| | | -| ---------------------------------------------------------------------------------------- | ----------------------------------- | -| `fork/dev` cut from the green `fork/integration` tip `21badd04e`, trees proven identical | tag `fork-dev/2026-08-06.1` | -| Default branch, ruleset, squash-only merges, required checks | live | -| CI wired for `fork/dev` pull requests and merges | #343 | -| Deployment promoting exact green `fork/dev` SHAs | ops `deploy.env` on the deploy host | -| Validation and release split into separate workflows | #347, #349 | -| First provenance sync, upstream `2a04db134..a2ca89aa1` | #345, tag `fork-dev/2026-08-06.2` | -| Upstream ancestry recorded so "behind" reads true | `3a7e7a458` | -| Integration overlays drained and deregistered | #348 | - -What is deliberately not done: clean downstream projection (see -[Deferred](#deferred--clean-downstream-projection)), automated provenance sync, and retirement of the -overlay machinery itself. - -`fork/changes` is frozen at `271c4b228` and `fork/integration` at `21badd04e`. Neither is a -contributor target or a release source any longer; they are kept only until the remaining PRs still -based on `fork/changes` are drained. - -The intended outcome, all of which now holds: - -- Contributors work against a stable branch whose history is never rewritten. -- A green merge can be released immediately or according to a configurable cadence. -- The useful upstream, base, Tim, and candidate provenance layers remain clean and rebased. -- Routine work does not require contributors to label desktop, mobile, server, or other affected - clients manually. -- Clean downstream history remains available as generated output rather than an authoring surface — - if and when it is actually wanted. - -## What Remains - -| Work | State | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| PRs still based on `fork/changes` | #317, #226, #185 conflict on rebase; #237 and #238 live in an external fork and need their author | -| Retire `fork/changes` and `fork/integration` | blocked on the above | -| Overlay and stack machinery | **removed.** `.github/pr-stack.json` survives only as the managed-PR allowlist for the draft lock | -| Automated provenance synchronization | manual, and fine at the current upstream cadence | -| Clean downstream projection | deferred indefinitely; nothing depends on it | -| Per-target release cadence | still immediate for everything | - -The rebased provenance stack (`main → fork/base → fork/tim → fork/candidates`) works exactly as it -did before and is unaffected by any of the above. - -## The Problem This Solved - -The runnable branch used to be produced through a fully rewritten stack: - -```text -upstream/main - -> fork/base - -> fork/tim - -> fork/candidates - -> fork/changes - -> identity/Discord/VS Code/desktop overlays - -> fork/integration -``` - -That gave the fork good provenance, but coupled ordinary development and releases to expensive -history maintenance: - -- Rebases change commit identities and invalidate contributor bases. -- Every upstream update can require the entire stack and all overlays to be rewritten. -- Small bot, server, desktop, or mobile fixes cannot ship until unrelated layers are green again. -- Conflicts in an early provenance layer block downstream releases. -- Permanent overlays add composition and synchronization work to otherwise ordinary features. - -Git cannot provide stable commit identities and continuously rebased history on the same branch. -The solution is to give stable development and clean projection different branches and different -responsibilities. - -## Topology - -A clean provenance stack is retained through `fork/candidates`, and its changes are fed into a -stable, complete development branch: - -```text -upstream/main - -> fork/base - -> fork/tim - -> fork/candidates periodically rebased provenance stack - | - | reviewed tree-delta synchronization - v - fork/dev stable complete product - ^ | - | +-> CI -> immediate or scheduled releases - | - +-- ordinary downstream PRs - +-- identity work - +-- Discord work - +-- VS Code work - +-- web/mobile/desktop/server/bot work - - (optional, later) - fork/dev checkpoint - -> fork/changes-clean - -> fork/integration-clean generated on demand, never a PR base -``` - -## Branch Responsibilities - -| Branch | Rewritten | Contributor target | Release source | Purpose | -| ------------------------ | ------------------- | ------------------ | -------------- | --------------------------------------------- | -| `main` | Yes, mirror-managed | No | No | Exact upstream mirror | -| `fork/base` | Yes | No | No | Fork repository and CI infrastructure | -| `fork/tim` | Yes | No | No | Selected Tim imports with provenance | -| `fork/candidates` | Yes | No | No | Selected unmerged upstream candidates | -| `fork/dev` | Never | Yes | Yes | Canonical complete downstream product | -| `fork/changes` | Was | No, frozen | No | Superseded by `fork/dev`; retire once drained | -| `fork/integration` | Was | No, frozen | No | Superseded by `fork/dev`; retire once drained | -| `fork/changes-clean` | Yes | No | No (optional) | Curated downstream projection, if built | -| `fork/integration-clean` | Yes | No | No (audit) | Clean composed output matching a checkpoint | - -The clean branch names are placeholders and only matter once that work is actually scheduled. - -## Core Invariants - -### Stable development - -- `fork/dev` is never rebased or force-pushed after cutover. -- Ordinary contributor PRs target `fork/dev`. -- Contributor branches never depend on generated projection branches. -- The complete application can be built, tested, and run directly from `fork/dev`. -- A clean-stack rebuild is not required for a routine release. - -### Exact releases - -- Every release refers to an exact, green `fork/dev` SHA. -- Deployment receives the same SHA that passed the required checks. -- Bot, server, desktop, and mobile release status can be recorded independently. -- Release timing is policy, not branch topology: it may be immediate, debounced, scheduled, or - manually promoted. - -### Clean provenance - -- `main`, `fork/base`, `fork/tim`, and `fork/candidates` retain their current provenance roles. -- `upstream/main` is merged into `fork/dev` directly. The rebased provenance branches are not - merged into it at all — their commit identities change, so merging a tip would duplicate history. -- Any generated clean branches are output only, may be rewritten safely, and are never merged back. - -## How the Cutover Was Done - -A ref change plus branch protection, not a re-architecture. - -1. `fork/dev` created from the green `fork/integration` tip `21badd04e`. -2. Trees proven identical — `git diff --exit-code fork/integration fork/dev`, both `a8a2b757a`. -3. Incorporated `fork/candidates` checkpoint recorded in tag `fork-dev/2026-08-06.1`. -4. `fork/dev` protected by the ruleset _Protect fork/dev (PR + CI)_: `deletion`, - `required_linear_history`, `non_fast_forward`, `pull_request` restricted to squash, and required - checks `Check` / `Test` / `Mobile Native Static Analysis` / `Release Smoke`. -5. Merge commits and rebase merging disabled repository-wide, so squash is the only method; the sync - automation holds a ruleset bypass actor so the upstream merge can still be pushed. -6. `fork/dev` made the GitHub default branch and the base for contributor PRs. -7. Deployment pointed at `fork/dev` (see [Ops](#ops)). - -Required checks were added **after** CI was wired to run for `fork/dev`. Requiring a check that -cannot yet run blocks the very PR that makes it runnable — the ordering matters. - -`strict_required_status_checks_policy` is deliberately `false`. Strict would invalidate every open -PR's checks on each merge; it is safe to relax because every merge SHA is validated by its own push -run, and deployment only promotes green SHAs. - -### Overlays were landed, not skipped - -Because `fork/dev` was cut from `fork/integration` — not from `fork/changes` — overlay content was -present in `fork/dev` from its first commit. That was deliberate: the runnable product must not -regress at cutover. - -The consequence was that every registered overlay had to be drained rather than left open. Leaving -one open against `fork/changes` would duplicate its commits the next time anything composed. All -four were verified contained **by path**, not by subject — the only commits not present touched -`pnpm-lock.yaml` alone, regeneration artifacts that compose discards by its own rule — then closed -and deregistered in #348. - -### What the cutover surfaced - -The old path hid several faults that only a real cutover could expose. They are recorded because -each cost a round trip: - -- **`fork/dev` had no CI path at all.** `fork-ci.yml` listed only the rebased stack layers and the - overlays as `pull_request` bases and had no `push` trigger, so required checks were impossible and - no run would ever exist for a merge SHA (#343). -- **Mobile releases would have stopped silently.** The release jobs were gated on - `workflow_dispatch` + `fork/integration`; nothing errors when they simply never fire again (#343). -- **Both mobile workflows hardcoded `ref: fork/integration`** and then asserted the requested SHA was - contained by that checkout, so every `fork/dev` SHA was rejected (#344, #346). -- **A release failure could veto a validation verdict.** A failed EAS dispatch marked a valid SHA - unapprovable and stranded the whole fleet, which is why validation and release are now separate - workflows (#347). -- **Every PR based on `fork/changes` was already broken.** Because `fork/changes` had been rebased, - GitHub reported them as 60–100 commits and 629–741 changed files. Each turned out to be one commit - of real work on stale history; the fix was to cherry-pick that commit onto `fork/dev`, not to - replay the branch. - -## Releasing from `fork/dev` - -Every release refers to an exact green `fork/dev` SHA. Two workflows, deliberately separate: - -| Workflow | Answers | Consumed by | -| ------------------ | ---------------------------- | ---------------------------------------- | -| `fork-ci.yml` | "is this SHA valid?" | the deploy poller, and branch protection | -| `fork-release.yml` | "release this validated SHA" | nothing — terminal | - -`fork-release` is chained on `workflow_run` and starts only after `fork-ci` **concludes success** for -a `push` on `fork/dev`. Its manual `workflow_dispatch` path re-checks that green verdict against the -API rather than trusting the operator. - -This split exists because a release action must never be able to veto a validation verdict. When -mobile dispatch lived inside `fork-ci`, a failed EAS call marked a valid SHA unapprovable and -stranded server, Discord, desktop and VS Code promotion of a perfectly good commit. - -Consequences worth knowing: - -- Release failures are recorded against the release run. The poller queries `fork-ci.yml` and never - sees them. -- `workflow_run` only fires for workflow files on the **default branch**. This works because - `fork/dev` is the default branch; it would silently do nothing otherwise. -- Server, Discord, desktop and VS Code are promoted by the poller pulling from the green `fork-ci` - run. Only mobile is dispatched by `fork-release`. - -The release summary reports what the SHA would move, classified by -`scripts/classify-deployment-diff.sh` — the same script the poller runs, so the report and the fleet -cannot disagree about what a diff means. It compares against the previous _released_ SHA, while the -poller compares against the last SHA it actually _deployed_; when those diverge the poller selects a -superset of the reported targets, never a subset. - -### Inferring release scope - -Daily contributors do not manually classify their work as web, mobile, desktop, server, or bot. -`scripts/classify-deployment-diff.sh` derives the affected surfaces from the diff, and both the -poller and `fork-release` run it, so no label is required to ship. - -**Check selection is not inferred and probably should not be.** Every PR into `fork/dev` runs all -four required checks regardless of the paths it touches. That is the conservative choice, and until -the suite is slow enough to hurt it is also the correct one: a path filter that is wrong in the -narrow direction silently skips a check on a protected branch. - -Illustrative path rules: - -```text -apps/mobile/** -> mobile checks and release scope -apps/desktop/** -> desktop checks and release scope -apps/server/** -> server and bot checks/release scope -apps/web/** -> web checks -apps/discord/** -> Discord checks -apps/vscode/** -> VS Code checks -packages/contracts/** -> all relevant producers and consumers -packages/client-runtime/** -> web and mobile -shared build/config paths -> conservative full validation -``` - -Unknown or ambiguous shared paths fail safely by selecting broader scope, never by requiring a -label. If check selection is ever narrowed, it must be driven by this same classifier so the two -cannot disagree about what a diff means. - -Labels remain appropriate only for exceptional intent that cannot be inferred from code — for -example requesting an unusual release behaviour, or recording an unusual external import. - -### Release policies - -`fork/dev` supports multiple release policies without changing the branch model. - -**Immediate — the current policy for every target.** After a merge, `fork-ci` validates the exact -resulting SHA, `fork-release` infers affected targets from the previous released SHA and dispatches, -and the poller promotes the rest within its 30-second cycle. - -**Lagged — available, not adopted.** Merges could instead accumulate and be promoted after a -debounce period, every few hours, daily, or at a manually selected checkpoint, on different schedules -per product. That is a policy change in `fork-release` and the poller cadence; it needs no additional -integration branches. - -Approved releases reference immutable checkpoints, for example `fork-dev/2026-08-06.1`. Each -deployment record should include the `fork/dev` SHA, CI run and conclusion, calculated change scope, -and independent bot / server / desktop / mobile status. **A partial multi-target release must not be -represented as completely deployed.** - -## Ops - -Deployment already promoted an exact CI-approved SHA, so the cutover was a _ref_ change, not a -mechanism change. The private ops repository had hardcoded `fork/integration` in the poller, -deployer, clone preparation, failure notifier and laptop catch-up; those now resolve through one -shared library: - -```sh -T3CODE_DEPLOY_BRANCH=fork/dev # default fork/integration -T3CODE_DEPLOY_CI_EVENT=push # default workflow_dispatch -T3CODE_CONTRIBUTOR_BRANCH=fork/dev # default fork/changes -T3CODE_DEPLOY_CI_WORKFLOW=fork-ci.yml -``` - -Set in `deploy.env` on the deployment host, which the poller unit reads via `EnvironmentFile=-` and -the library parses directly as plain `KEY=VALUE` — never sourced as shell, so a config file cannot -execute code. Defaults reproduce the pre-cutover behaviour exactly, so reverting is deleting that -file. - -Component checkpoint state files (`fork-integration--sha`) keep their names across the -cutover on purpose. Their value is the last deployed SHA used for tree-diff classification, and -because `fork/dev` started at the `fork/integration` tip those SHAs remained valid ancestors — so the -cutover did not trigger a full fleet redeploy. - -The poller keys on a successful `fork-ci` **push** run for the exact SHA. Because release actions -were moved out of that workflow, a release failure can no longer withhold a fleet promotion. - -## Daily Contributor Workflow - -After step 1 the normal path is: - -1. Create a feature branch from `fork/dev`. -2. Open a PR against `fork/dev`. -3. Run the mandatory local validation. -4. Run GitHub CI for the exact PR and merge tip. -5. Squash merge. -6. Release immediately or in the next release cadence. - -No restack or overlay composition is required for an ordinary feature or fix. - -### Merge policy: squash (decided) - -**Squash merge is the standard for PRs into `fork/dev`.** Every GitHub PR becomes exactly one stable -commit, which is what makes the rest of this document work: the PR ledger maps one-to-one onto -`fork/dev` history, release scope is a diff between two commits, and a later clean projection has a -single unit to replay per PR. - -One exception is known: **provenance sync PRs are merge-committed, not squashed**, because -squashing discards the upstream ancestry link that makes "commits behind upstream" readable. See -[Synchronizing Upstream](#synchronizing-upstream-into-forkdev). - -Beyond that, exceptions are not defined. If a case appears where preserving a dependent series on -`fork/dev` genuinely matters, it can be argued on its own merits then. - -Enforce this in repository settings rather than by asking contributors to pick the right button. -GitHub has no "default merge method" field: the merge button's primary action is whichever method is -enabled, in the order merge commit → squash → rebase. Squash only becomes the default by disabling -the other two: - -```sh -gh api -X PATCH repos// \ - -F allow_squash_merge=true -F allow_merge_commit=false -F allow_rebase_merge=false -``` - -That also makes an exception a deliberate act — someone has to re-enable a method to take one — which -is the right shape for a policy whose exceptions are undefined. Pair it with -`required_linear_history` on `fork/dev` so the invariant holds even if a setting is changed later. - -Leave merge commits disabled even though provenance syncs need one. Re-enabling them repo-wide makes -**Merge** the merge button's primary action again — GitHub picks it in the order merge → squash → -rebase — which quietly reverses this decision for every ordinary PR. Give the sync automation a -ruleset **bypass actor** instead, so it can push the upstream merge directly while every human path -stays squash-only. - -Release only the exact merge SHA after its required checks pass. A green PR tip is not sufficient if -the resulting merge SHA differs or the base moved. - -## Identity, Discord, and VS Code - -Identity, Discord, and VS Code remain meaningful ownership areas, but they do not need permanent -composition overlays. - -Their feature PRs target `fork/dev` directly. Ownership and validation are inferred from paths and -dependency impact — Discord's and VS Code's separate directories naturally select their own checks -and owners; shared package changes expand validation to affected consumers. Use path-based workflow -filters and `CODEOWNERS` instead of requiring contributors to apply client labels. - -If one of these areas genuinely needs independent staging, it may later use a stable, never-rebased -branch such as `fork/discord-dev`. Work merges into `fork/dev`, and `fork/dev` merges back -afterwards. This adds merge topology and administration, so introduce it only where separate staging -provides a concrete benefit. Clear folders alone are not sufficient justification. - -Likewise, ordinary cross-cutting features — the desktop URL-handler enhancement, for example — -become ordinary squash-merged PRs on `fork/dev`, not permanent layers. A dedicated layer is -justified only when work has independent external provenance or must remain independently staged. - -## Synchronizing Upstream into `fork/dev` - -Merge it: - -```sh -git fetch upstream main -git merge upstream/main # resolve, verify, then push to fork/dev -git push origin upstream/main:main # fast-forward the mirror -``` - -`upstream/main` is append-only and its commit identities are permanent, so a merge is sound and -`fork/dev` gains upstream's commits as real ancestors. "Commits behind upstream" then reads true on -the branch page, and the merge base advances, so the next sync replays only what is genuinely new. -The first such merge replayed three commits, not the whole divergence. - -Do **not** press GitHub's **Sync fork** button. It merges into the default branch on GitHub's terms -rather than after local verification, and there is no opportunity to resolve or run the gate first. - -### Verify before pushing, not after - -The merge lands directly on `fork/dev` — it cannot go through the squash-only PR path without -flattening the second parent and discarding the ancestry. So the merge is validated locally and -pushed with a ruleset bypass, rather than reviewed in a PR. - -That makes local verification the only gate, and it must be the full one: recursive typecheck and the -test suite. **A clean textual merge is not evidence of a working one.** Every sync so far has hit -integration breakage that produced no conflict at all — most recently upstream adding a parameter to -`resolveSnoozePresets` and updating its own call sites, while the fork's board code kept the old -arity. Git had nothing to say about it; the typechecker did. - -### What this replaced - -Earlier syncs rebuilt `fork/base` → `fork/tim` → `fork/candidates` onto the new upstream tip, -computed the tree delta between the previous and new `fork/candidates` trees, applied that delta to -`fork/dev`, and then recorded ancestry with `git merge -s ours`. - -That existed because those provenance branches are rebased, so merging their tips would duplicate -history. It was never necessary for upstream itself, which is not rebased. The rebuild also meant a -conflict in an early layer blocked the import entirely — the exact coupling this whole document set -out to remove, reintroduced one level up. The stack tooling that drove it has been removed. - -`fork/base`, `fork/tim` and `fork/candidates` still exist and still record which upstream and Tim -work was selected, but they are no longer rebuilt on every upstream update. - -## GitHub PRs as the Development Ledger - -GitHub PR history is the primary daily ledger. Do not create a second manifest containing every -normal contributor PR. Merge order, stable squash commits, changed paths, PR relationships, and -checkpoint tags already carry the information. - -Explicit metadata is required only when the work cannot speak for itself. Tim and candidate import -PRs should record immutable source information: - -```text -Source-Repository: -Source-PR: -Source-SHA: -Fork-Layer: tim|candidate -``` - -This is worth adopting immediately even though the projection work is deferred — the trailers cost -nothing on an import PR and are hard to reconstruct afterwards. - -## Deferred — Clean Downstream Projection - -**Not required to reap the benefits above.** Skip this entire section until there is a concrete -need: upstreaming a series, an external audit, or a major release that wants a curated history. -Nothing in steps 1–3 depends on it. - -When it is scheduled, clean downstream history is generated on demand from a tagged, green -`fork/dev` checkpoint plus rebuilt `fork/base`, `fork/tim`, and `fork/candidates`: - -1. Select and tag a green `fork/dev` checkpoint. -2. Update the upstream mirror; rebuild and verify `fork/base`, `fork/tim`, and `fork/candidates`. -3. Select the downstream PRs represented in the checkpoint, excluding provenance-sync commits whose - content is already represented below. -4. Replay downstream features into `fork/changes-clean`, folding explicitly linked repairs - (`Projection-Fold-Into: `) into their owning feature commits. -5. Compose `fork/integration-clean` and run the per-layer gate in stop-the-line order. -6. Verify tree equivalence with the selected checkpoint: - - ```bash - git diff --exit-code - ``` - -7. Publish generated branches only after all checks pass. - -Expected differences must be narrowly documented and machine-verifiable; broad path exclusions are -not acceptable, because they are how a projection silently drops downstream behaviour. - -A projection failure blocks publication of the generated clean stack only. It does not rewrite or -block `fork/dev`, does not block releases from an already-green `fork/dev` SHA, and produces an -actionable report identifying the PR, layer, commit, and conflicting paths. - -A small manifest remains useful for machine policy GitHub cannot infer — layer ordering, external -import sources, persistent conflict-resolution rules, explicit exclusions, ordering constraints, -fix-to-feature folding overrides, and exceptional commits that did not originate in a PR. It should -not duplicate the GitHub PR overview. - -The existing full stop-the-line per-layer gate is retained for this work. It is appropriate for -periodic provenance maintenance and must not sit in the path of a routine product release. - -## Remaining Migration Steps - -Steps 1 and 2 — establishing `fork/dev` and releasing from it — are done; see -[How the Cutover Was Done](#how-the-cutover-was-done) and -[Releasing from `fork/dev`](#releasing-from-forkdev). - -### Finish draining `fork/changes` - -The registered overlays are drained and deregistered. What is left are the ordinary PRs still based -on `fork/changes`: #317, #226 and #185 conflict when their real commit is cherry-picked onto -`fork/dev`, and #237 and #238 live in an external fork and need their author. Once those are -resolved, `fork/changes` and `fork/integration` can be deleted. The composition workflows and stack -tooling are already gone. - -### Automate the upstream merge (when manual becomes tedious) - -The merge itself is one command; what takes the time is verifying it and resolving the integration -breakage that produces no conflict. Automating the mechanical half is easy and would help least. - -### Automate clean projection (only if needed) - -Only once the deferred projection below is actually wanted: query PRs between checkpoint tags, infer -ordinary changes from commits and paths, replay into clean layers, apply durable conflict policies, -verify every rewritten layer, prove tree equivalence, and publish. - -## Operational Rules - -1. Never force-push or rebase `fork/dev`. -2. Merge `upstream/main` into `fork/dev` directly, and never a rebased provenance tip. -3. Never require a clean projection rebuild to ship an unrelated urgent fix. -4. Never release an untested `fork/dev` SHA; release the exact merge SHA, not the PR tip. -5. Every ordinary product change enters through a GitHub PR, squash merged. -6. Never let a release action decide whether a SHA is valid. -7. Daily contributors do not manually classify affected clients when paths and dependencies can. -8. External imports record immutable source provenance at import time. -9. If generated projection branches exist, never merge them into `fork/dev` and never use one as a - PR base. - -## Settled Decisions - -- **Merge policy: squash.** Every PR into `fork/dev` becomes one commit, enforced by disabling merge - and rebase merging repository-wide and by the `fork/dev` ruleset. The one exception is the upstream - merge, pushed with a ruleset bypass because squashing it would discard the second parent and with - it the upstream ancestry. See [Merge policy](#merge-policy-squash-decided). -- **Validation and release are separate workflows.** `fork-ci` decides validity; `fork-release` - acts on it. See [Releasing from `fork/dev`](#releasing-from-forkdev). -- **Upstream is merged directly into `fork/dev`**, not imported as a tree delta from a rebuilt - provenance stack. `fork/dev` therefore carries upstream's real commits and "commits behind - upstream" reads true. See [Synchronizing Upstream](#synchronizing-upstream-into-forkdev). -- **The stack and overlay tooling is removed.** Nothing rebuilds `fork/base`, `fork/tim` or - `fork/candidates` on an upstream update. - -## Open Decisions - -None of these block anything currently running: - -- Release cadence per target. Everything is immediate today; lagged promotion is available. -- Frequency of upstream synchronization. -- Whether identity, Discord, or VS Code needs a stable subsystem staging branch. -- Whether clean downstream projection is ever built, and if so on what trigger. -- Naming of the generated clean branches. -- Exact rules for mapping shared-package changes to downstream consumers. -- When to delete `fork/changes` and `fork/integration`, and whether to close the three - provenance PRs (#255, #1, #27) now that nothing rebuilds their branches. - -## The Operating Principle - -> Rebase external provenance; squash stable product development onto `fork/dev`; release exact green -> `fork/dev` checkpoints; generate clean downstream history separately — and only when it is -> actually needed. - -The cutover took a single day: `fork/dev` cut from the green `fork/integration` tip, overlays -drained, the ops deploy branch flipped, and releases running from exact green SHAs. It removed daily -rebases from contributor and release workflows and removed routine label administration, while -preserving the ability to produce a clean, auditable downstream history later if it is ever worth the -cost. diff --git a/scripts/agent-pr-ready.mjs b/scripts/agent-pr-ready.mjs index d7fdae5c3d2d..dcaa1d7c3977 100644 --- a/scripts/agent-pr-ready.mjs +++ b/scripts/agent-pr-ready.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** - * Agent (and human) publish path: run the ship gate, then mark the PR ready. + * Agent (and human) publish path: require HEAD to contain latest fork/dev, + * run the full ship gate, then mark the PR ready. * * pnpm pr:ready * @@ -13,6 +14,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeProcess from "node:process"; import { runAgentShipGate } from "./agent-pre-push.mjs"; +import { assertUpToDateWithForkDev } from "./lib/agent-fork-dev.mjs"; import { resolveOpenPrState } from "./lib/agent-pr-state.mjs"; const root = NodeProcess.cwd(); @@ -30,6 +32,13 @@ if (prState.mode === "unknown") { NodeProcess.exit(1); } +const forkDev = assertUpToDateWithForkDev({ cwd: root }); +if (!forkDev.ok) { + console.error(`agent pr:ready: ${forkDev.detail}`); + NodeProcess.exit(1); +} +console.error(`agent pr:ready: up to date with ${forkDev.ref}`); + if (prState.mode === "ready") { console.error( `agent pr:ready: PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""} is already ready — running ship gate only`, @@ -38,9 +47,9 @@ if (prState.mode === "ready") { NodeProcess.exit(0); } -// draft → gate then undraft +// draft → full gate then undraft console.error( - `agent pr:ready: ship gate then ready PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""}`, + `agent pr:ready: full ship gate then ready PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""}`, ); await runAgentShipGate({ root }); diff --git a/scripts/agent-pre-push.mjs b/scripts/agent-pre-push.mjs index 0094bbad4db0..ffa1b0dfabac 100644 --- a/scripts/agent-pre-push.mjs +++ b/scripts/agent-pre-push.mjs @@ -5,16 +5,17 @@ * Humans: no-op (exit 0) — self-responsible; not forced by the hook. * * Agents: - * - Draft PR or no open PR: static gate only — steps 1–2 below. - * A push that does not lint/typecheck helps nobody, draft or not; what - * draft buys is skipping the unit suite, not skipping correctness. + * - Draft PR or no open PR: changed-file `vp check` (fmt + lint of files + * changed against fork/dev) plus workspace `vpr typecheck`. + * Commits already pay lint-staged. * - Ready-for-review PR (or unknown PR state): full ship gate (1–3). - * - Publish path is `pnpm pr:ready` (not raw `gh pr ready`) — same full gate, - * then undraft. See scripts/agent-pr-ready.mjs + scripts/agent-gh.mjs. + * - Publish path is `pnpm pr:ready` (not raw `gh pr ready`) — catch up to + * fork/dev, run the full gate, then undraft. + * See scripts/agent-pr-ready.mjs + scripts/agent-gh.mjs. * * Ship gate mirrors the CI JS quality path: - * 1. `vp check` — format + lint - * 2. `vpr typecheck` — workspace TypeScript + * 1. `vp check` — format + lint (changed files on draft; workspace on full) + * 2. `vpr typecheck` — workspace TypeScript (draft and full) * 3. `vp run test` — unit tests (full / publish only) * * Detection: GROK_AGENT / T3_AGENT / AI_AGENT / Claude / Cursor / Codex env. @@ -26,6 +27,7 @@ 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 { listChangedFilesAgainstForkDev } from "./lib/agent-fork-dev.mjs"; import { resolveOpenPrState, shipGateScopeForPush } from "./lib/agent-pr-state.mjs"; import { isShipGateForce, @@ -73,43 +75,55 @@ const run = (label, args, opts = {}) => { }; /** - * Agent ship gate: static (check + typecheck) and optionally full (+ unit tests). - * Shared by pre-push (every agent push) and `pnpm pr:ready`. + * Agent ship gate. Shared by pre-push and `pnpm pr:ready`. * - * `scope: "static"` stops after typecheck — draft / no-PR pushes. - * `scope: "full"` (default) adds `vp run test` — ready PRs and publish. + * `scope: "changed"` — `vp check` on files changed against fork/dev, then + * workspace `vpr typecheck` (draft / no-PR). + * `scope: "full"` (default) — workspace `vp check` + `vpr typecheck` + `vp run test`. * - * 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. + * Caches HEAD SHA under `.run/agent-ship-gate.json` so a full run is never + * double-paid for the same commit. Changed-file runs are not cached as full. + * Force: AGENT_SHIP_GATE_FORCE=1. * - * @param {{ root?: string, scope?: "full" | "static", force?: boolean, env?: NodeJS.ProcessEnv }} [opts] - * @returns {Promise<{ status: "cached" | "ok", sha: string | null, scope: "full" | "static" }>} + * @param {{ root?: string, scope?: "full" | "changed", force?: boolean, env?: NodeJS.ProcessEnv }} [opts] + * @returns {Promise<{ status: "cached" | "ok", sha: string | null, scope: "full" | "changed" }>} */ export const runAgentShipGate = async (opts = {}) => { const root = opts.root ?? NodeProcess.cwd(); const env = withRepoBin(root, opts.env ?? NodeProcess.env); const force = opts.force === true || isShipGateForce(env); - const scope = opts.scope === "static" ? "static" : "full"; + const scope = opts.scope === "changed" || opts.scope === "static" ? "changed" : "full"; const headSha = readHeadSha(root); const cache = readShipGateCache(root); - const alreadyCached = - scope === "static" - ? !force && headSha && isShipGateStaticCached(headSha, cache) - : !force && headSha && isShipGateShaCached(headSha, cache); + if (scope === "changed") { + const files = listChangedFilesAgainstForkDev({ cwd: root }); + if (files.length === 0) { + console.error("agent ship-gate: no files changed against fork/dev — skip fmt/lint"); + } else { + run("vp check (changed)", ["vp", "check", "--no-error-on-unmatched-pattern", ...files], { + cwd: root, + env, + }); + } + run("vpr typecheck", ["vpr", "typecheck"], { cwd: root, env }); + console.error( + `agent ship-gate: changed ok — ${files.length} file${files.length === 1 ? "" : "s"} + typecheck; full gate on publish`, + ); + return { status: "ok", sha: headSha, scope }; + } + + const alreadyCached = !force && headSha && isShipGateShaCached(headSha, cache); if (alreadyCached) { console.error( - `agent ship-gate: skip — ${headSha.slice(0, 12)} already validated${ - scope === "static" ? " (static)" : "" - } (cache .run/agent-ship-gate.json; force with AGENT_SHIP_GATE_FORCE=1)`, + `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, scope }; } const staticCached = !force && headSha && isShipGateStaticCached(headSha, cache); - // Mirror CI check + typecheck (always). Unit tests only on full scope. if (staticCached) { console.error( `agent ship-gate: skip check/typecheck — ${headSha.slice(0, 12)} already passed static`, @@ -119,23 +133,6 @@ export const runAgentShipGate = async (opts = {}) => { run("vpr typecheck", ["vpr", "typecheck"], { cwd: root, env }); } - if (scope === "static") { - if (headSha) { - try { - writeShipGateCache(root, headSha, { stage: "static" }); - console.error( - `agent ship-gate: cached static ${headSha.slice(0, 12)} (.run/agent-ship-gate.json)`, - ); - } catch (error) { - console.error(`agent ship-gate: could not write static cache: ${error?.message ?? error}`); - } - } - console.error( - "agent ship-gate: static ok — unit tests run on ready / publish (`pnpm pr:ready`)", - ); - return { status: "ok", sha: headSha, scope }; - } - run("vp run test", ["vp", "run", "test"], { cwd: root, env }); if (headSha) { @@ -163,13 +160,13 @@ if (invokedAs === thisFile) { const prState = resolveOpenPrState({ cwd: root }); const scope = shipGateScopeForPush(prState.mode); - if (scope === "static") { + if (scope === "changed") { const why = prState.mode === "draft" ? `draft PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""}` : "no open PR"; console.error( - `agent pre-push: ${why} — static gate only (check + typecheck; unit tests on publish via pnpm pr:ready)`, + `agent pre-push: ${why} — changed-file check + typecheck (full gate on publish via pnpm pr:ready)`, ); } else if (prState.mode === "unknown") { console.error( diff --git a/scripts/agent-pre-push.test.mjs b/scripts/agent-pre-push.test.mjs index 817911fd3be6..26a99710051f 100644 --- a/scripts/agent-pre-push.test.mjs +++ b/scripts/agent-pre-push.test.mjs @@ -4,6 +4,11 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { isCodingAgent } from "./agent-pre-push.mjs"; import { findRealGh, requiresShipGate, stripGhGlobalFlags } from "./lib/agent-gh-policy.mjs"; +import { + assertUpToDateWithForkDev, + listChangedFilesAgainstForkDev, + resolveForkDevRef, +} from "./lib/agent-fork-dev.mjs"; import { classifyPrPayload, parseRepoSlug, @@ -52,6 +57,61 @@ it("PR payload: draft / ready / closed", () => { assert.equal(classifyPrPayload({ isDraft: true, state: "CLOSED" }), "none"); }); +it("resolveForkDevRef: prefers origin/fork/dev", () => { + const seen = []; + const ref = resolveForkDevRef({ + cwd: "/repo", + runGit: (args) => { + seen.push(args); + if (args.includes("origin/fork/dev")) return { status: 0, stdout: "abc\n" }; + return { status: 1, stdout: "" }; + }, + }); + assert.equal(ref, "origin/fork/dev"); + assert.equal(seen[0]?.includes("origin/fork/dev"), true); +}); + +it("listChangedFilesAgainstForkDev: diffs merge-base..HEAD", () => { + const files = listChangedFilesAgainstForkDev({ + cwd: "/repo", + runGit: (args) => { + if (args.includes("rev-parse")) return { status: 0, stdout: "abc\n" }; + if (args[0] === "merge-base" && args[1] === "HEAD") return { status: 0, stdout: "base123\n" }; + if (args[0] === "diff") return { status: 0, stdout: "AGENTS.md\nscripts/x.mjs\n" }; + return { status: 1, stdout: "" }; + }, + }); + assert.deepEqual(files, ["AGENTS.md", "scripts/x.mjs"]); +}); + +it("assertUpToDateWithForkDev: fails when fork/dev is not an ancestor", () => { + const result = assertUpToDateWithForkDev({ + cwd: "/repo", + fetch: false, + runGit: (args) => { + if (args.includes("rev-parse")) return { status: 0, stdout: "abc\n" }; + if (args.includes("--is-ancestor")) return { status: 1, stdout: "" }; + return { status: 0, stdout: "" }; + }, + }); + assert.equal(result.ok, false); + assert.match(result.detail ?? "", /not up to date/); +}); + +it("assertUpToDateWithForkDev: ok when fork/dev is an ancestor", () => { + const result = assertUpToDateWithForkDev({ + cwd: "/repo", + fetch: false, + runGit: (args) => { + if (args.includes("rev-parse")) return { status: 0, stdout: "abc\n" }; + if (args.includes("--is-ancestor")) return { status: 0, stdout: "" }; + return { status: 0, stdout: "" }; + }, + }); + assert.equal(result.ok, true); + assert.equal(result.ref, "origin/fork/dev"); +}); + it("full ship gate on push: only ready + unknown", () => { assert.equal(shouldRunShipGateOnPush("none"), false); assert.equal(shouldRunShipGateOnPush("draft"), false); @@ -59,9 +119,9 @@ it("full ship gate on push: only ready + unknown", () => { assert.equal(shouldRunShipGateOnPush("unknown"), true); }); -it("push scope: draft/none = static, ready/unknown = full", () => { - assert.equal(shipGateScopeForPush("none"), "static"); - assert.equal(shipGateScopeForPush("draft"), "static"); +it("push scope: draft/none = changed, ready/unknown = full", () => { + assert.equal(shipGateScopeForPush("none"), "changed"); + assert.equal(shipGateScopeForPush("draft"), "changed"); assert.equal(shipGateScopeForPush("ready"), "full"); assert.equal(shipGateScopeForPush("unknown"), "full"); }); diff --git a/scripts/lib/agent-fork-dev.mjs b/scripts/lib/agent-fork-dev.mjs new file mode 100644 index 000000000000..005162d2288d --- /dev/null +++ b/scripts/lib/agent-fork-dev.mjs @@ -0,0 +1,102 @@ +/** + * fork/dev helpers for the agent ship gate. + * + * Draft / no-PR pushes lint+format only the files changed against fork/dev. + * Publishing requires HEAD to contain the current fork/dev tip. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeProcess from "node:process"; + +export const FORK_DEV_REFS = ["origin/fork/dev", "fork/dev"]; + +const defaultRunGit = (args, opts) => { + const result = NodeChildProcess.spawnSync("git", args, { + encoding: "utf8", + cwd: opts.cwd, + shell: false, + }); + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +}; + +/** + * @param {{ + * cwd?: string + * runGit?: (args: string[], opts: { cwd: string }) => { status: number | null, stdout: string } + * }} [opts] + * @returns {string | null} + */ +export const resolveForkDevRef = (opts = {}) => { + const cwd = opts.cwd ?? NodeProcess.cwd(); + const runGit = opts.runGit ?? defaultRunGit; + for (const ref of FORK_DEV_REFS) { + const result = runGit(["rev-parse", "--verify", ref], { cwd }); + if ((result.status ?? 1) === 0) return ref; + } + return null; +}; + +/** + * Files changed on HEAD relative to the merge-base with fork/dev. + * Falls back to the files in HEAD itself when fork/dev is missing. + * + * @param {{ + * cwd?: string + * runGit?: (args: string[], opts: { cwd: string }) => { status: number | null, stdout: string } + * }} [opts] + * @returns {string[]} + */ +export const listChangedFilesAgainstForkDev = (opts = {}) => { + const cwd = opts.cwd ?? NodeProcess.cwd(); + const runGit = opts.runGit ?? defaultRunGit; + const ref = resolveForkDevRef({ cwd, runGit }); + const result = (() => { + if (!ref) { + return runGit( + ["diff-tree", "--no-commit-id", "--name-only", "--diff-filter=ACMR", "-r", "HEAD"], + { cwd }, + ); + } + const mergeBase = runGit(["merge-base", "HEAD", ref], { cwd }); + const base = String(mergeBase.stdout ?? "").trim(); + if ((mergeBase.status ?? 1) !== 0 || !base) { + return runGit( + ["diff-tree", "--no-commit-id", "--name-only", "--diff-filter=ACMR", "-r", "HEAD"], + { cwd }, + ); + } + return runGit(["diff", "--name-only", "--diff-filter=ACMR", `${base}..HEAD`], { cwd }); + })(); + if ((result.status ?? 1) !== 0) return []; + return String(result.stdout ?? "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +}; + +/** + * @param {{ + * cwd?: string + * runGit?: (args: string[], opts: { cwd: string }) => { status: number | null, stdout: string } + * fetch?: boolean + * }} [opts] + * @returns {{ ok: true, ref: string } | { ok: false, detail: string }} + */ +export const assertUpToDateWithForkDev = (opts = {}) => { + const cwd = opts.cwd ?? NodeProcess.cwd(); + const runGit = opts.runGit ?? defaultRunGit; + if (opts.fetch !== false) { + runGit(["fetch", "origin", "fork/dev"], { cwd }); + } + const ref = resolveForkDevRef({ cwd, runGit }); + if (!ref) { + return { ok: false, detail: "cannot resolve origin/fork/dev (fetch it, then retry)" }; + } + const ancestor = runGit(["merge-base", "--is-ancestor", ref, "HEAD"], { cwd }); + if ((ancestor.status ?? 1) !== 0) { + return { + ok: false, + detail: `HEAD is not up to date with ${ref} — rebase or merge latest fork/dev before publishing`, + }; + } + return { ok: true, ref }; +}; diff --git a/scripts/lib/agent-pr-state.mjs b/scripts/lib/agent-pr-state.mjs index eb9cd590164f..b5ba0a5fbec4 100644 --- a/scripts/lib/agent-pr-state.mjs +++ b/scripts/lib/agent-pr-state.mjs @@ -2,8 +2,8 @@ * Resolve whether the current branch's open PR should enforce the agent ship gate. * * Modes: - * none — no open PR (or closed/merged): static gate only - * draft — open draft PR: static gate only (lighter than publish) + * none — no open PR (or closed/merged): changed-file check + typecheck + * draft — open draft PR: changed-file check + typecheck * ready — open non-draft PR: full ship gate on every agent push * unknown — gh missing / failed: fail closed (run the full gate) * @@ -11,8 +11,7 @@ * 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: + * direnv being loaded — so 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). */ @@ -40,14 +39,13 @@ export const shouldRunShipGateOnPush = (mode) => mode === "ready" || mode === "u /** * How much of the gate a push pays for. * - * Nothing is free any more: a push that does not format, lint or typecheck is - * worthless to a reviewer whether or not the PR says "draft". What draft buys - * is the *expensive* half — full unit suite stays on the ready/publish path. + * Draft / no-PR: format + lint of files changed against fork/dev, plus typecheck. + * Ready / unknown: full workspace check + typecheck + unit tests. * * @param {"none" | "draft" | "ready" | "unknown"} mode - * @returns {"full" | "static"} + * @returns {"full" | "changed"} */ -export const shipGateScopeForPush = (mode) => (shouldRunShipGateOnPush(mode) ? "full" : "static"); +export const shipGateScopeForPush = (mode) => (shouldRunShipGateOnPush(mode) ? "full" : "changed"); /** Strip ANSI color codes so `gh --json` stays parseable when color is forced. */ const stripAnsi = (text) => String(text).replace(/\u001b\[[0-9;]*m/g, "");