bump: next 15.4.3 → 16.2.6 + ESLint flat config (v9 fallback) + typescript devDep #4

Merged
varutasu merged 6 commits from convoy/bump-next-js into main 2026-05-23 03:31:27 -04:00
41 changed files with 4928 additions and 661 deletions

176
.agent-context-manifest.yml Normal file
View file

@ -0,0 +1,176 @@
# .agent-context-manifest.yml
#
# Generated by agent-pipeline bootstrap-agent-context skill.
# Tracks which artifacts the bootstrap installed in this repo, where they
# came from, and what pipeline version they correspond to.
#
# Read by the `sync-agent-context` skill to detect drift and propose updates.
# Don't edit by hand — use the bootstrap or sync skill in Cursor.
#
# Schema: https://github.com/varutasu/agent-pipeline/blob/main/docs/manifest-schema.md
schema_version: 1
pipeline_version: "0.5.0"
pipeline_source: "https://github.com/varutasu/agent-pipeline"
installed_at: "2026-05-22T22:25:00Z"
last_synced_at: "2026-05-22T22:25:00Z"
layers:
- L1
- L2
- L3
# Notes:
# - AGENTS.md is hand-curated per-repo — NOT tracked (always shows drift)
# - docs/SCHEMA_MAP.md is hand-curated per-repo — NOT tracked
# - .convoys/<slug>.md files are runtime outputs — NOT tracked
artifacts:
- path: ".convoys/README.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/convoys-readme.md.template"
version: "0.5.0"
installed_hash: "sha256:a48548cd3f5d0c40fc179106890661c3be5fcdc13eb705af7cfe9233e0b8b209"
- path: ".cursor/agents/role-a11y-auditor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-a11y-auditor.md"
version: "0.5.0"
installed_hash: "sha256:a59938deceb0246ebd7e477f1f9a442102f9fcbb81b0364f0ddc5f86e95a7930"
- path: ".cursor/agents/role-architect.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-architect.md"
version: "0.5.0"
installed_hash: "sha256:269bd62af1557c5d353a9f95a613960e3434be4ec6e0c0b5f6b099adf6872044"
- path: ".cursor/agents/role-conductor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-conductor.md"
version: "0.5.0"
installed_hash: "sha256:bc75a3e6646217a015f7bb60c3610afd9b57ae91c7d2fc7a7971f4709b19368a"
- path: ".cursor/agents/role-design-system-auditor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-design-system-auditor.md"
version: "0.5.0"
installed_hash: "sha256:d214cecb1e8482fc24f2815c8220c860191f08526614f89cf9a5797e4ee9110a"
- path: ".cursor/agents/role-doc-writer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-doc-writer.md"
version: "0.5.0"
installed_hash: "sha256:d4e8bf8cee93153506b7b742848462422dbe5cc7fd012c62f6ffd50460e344d4"
- path: ".cursor/agents/role-ia-architect.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-ia-architect.md"
version: "0.5.0"
installed_hash: "sha256:69685a3a407c4ee25e2606d426c3107d6b917abee80f907e16ade4a16b439839"
- path: ".cursor/agents/role-implementer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-implementer.md"
version: "0.5.0"
installed_hash: "sha256:b4f4d8596068679b90ffc3a2b6d2e1b6548caf8c68a50f7ed640ba8f638c1c4c"
- path: ".cursor/agents/role-reviewer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-reviewer.md"
version: "0.5.0"
installed_hash: "sha256:1ff38349321402a0ac2be37878dc2c0bcab62e54caf74c422b919aa6d75f9b67"
- path: ".cursor/agents/role-ux-reviewer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-ux-reviewer.md"
version: "0.5.0"
installed_hash: "sha256:3a1d4b66981f469b15e23a1cd34ab41352759966179e126b3d56ddc1eca4a03e"
- path: ".cursor/rules/api-routes.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/api-routes.mdc.template"
version: "0.5.0-local"
installed_hash: "sha256:54cd66d71f5a129a67d0f4b1797f5f63b7f9aae3eeabe67217861456ff4db59b"
- path: ".cursor/rules/auth-and-permissions.mdc"
source: "tcg-vault-local"
version: "0.5.0-local"
installed_hash: "sha256:9b7eb7bea0cad0e43d0e9442eb8b660945cb6935f9f7c82fd7d7d71d66b39a2d"
- path: ".cursor/rules/db-and-schema.mdc"
source: "tcg-vault-local"
version: "0.5.0-local"
installed_hash: "sha256:83df2cf7121722a092f85165b1a93c755ce57e5361e0f6b2ecc74e9f928c5015"
- path: ".cursor/rules/no-go-zones.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/no-go-zones.mdc"
version: "0.5.0-local"
installed_hash: "sha256:aa7046bc3e0266cb3c9b0eb0ef8f68cc50d6837f65c96861804ff81b9c4afa64"
- path: ".cursor/rules/schema-map.mdc"
source: "tcg-vault-local"
version: "0.5.0-local"
installed_hash: "sha256:3429bad56384117dc81873b337a6d815bd53799389f7908dedb53dbb7642bced"
- path: ".cursor/rules/ui-and-theming.mdc"
source: "tcg-vault-local"
version: "0.5.0-local"
installed_hash: "sha256:b841ddda5baa47a76c3726a3c92b2c82a45120fdd461b3243bf970479d1cf1df"
- path: ".cursor/skills/add-api-route/SKILL.md"
source: "tcg-vault-local"
version: "0.5.0-local"
installed_hash: "sha256:0e29f7e994a51e5a40b8308edab08ee9c1f713e297d68e98029294d8ca568cc7"
- path: ".cursor/skills/add-page/SKILL.md"
source: "tcg-vault-local"
version: "0.5.0-local"
installed_hash: "sha256:318912077a6ced6a3a31f85dc15d069bf7627c161b6735e3fa259ca10766daa9"
- path: ".github/CODEOWNERS"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs/CODEOWNERS.template"
version: "0.5.0-local"
installed_hash: "sha256:b714a0a011776300abeab92fe8969f150c273c37d0d6b37c1ad2eb67d47decda"
- path: ".github/PULL_REQUEST_TEMPLATE.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/PULL_REQUEST_TEMPLATE.md.template"
version: "0.5.0"
installed_hash: "sha256:89863e58b9ec194aef1c94d3596e892467833e8bc880a28994acca401b6d9635"
- path: ".github/workflows/agent-context-drift.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/agent-context-drift.yml.template"
version: "0.5.0"
installed_hash: "sha256:5505c296c1b61d023ee2aca222103097e2b5ed2e0e38da3679cc4f9754457785"
- path: ".github/workflows/ci.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs/ci.yml.template"
version: "0.5.0-local"
installed_hash: "sha256:6aff7a1c9f2e42606580c241b6dadca7c2d8550aeb959bd69fdd843eb9097cac"
- path: ".github/workflows/pr-health-rollup.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma-vercel/pr-health-rollup.yml.template"
version: "0.5.0-local"
installed_hash: "sha256:8747674323807d84395fa027b25e7e27881c5e6b0cc87a138d9cb3f78fd88956"
- path: ".github/workflows/preview-smoke.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma-vercel/preview-smoke.yml.template"
version: "0.5.0-local"
installed_hash: "sha256:2e71026b09db8b2f32b6a868d705489600c875082d6320c2369bf2f5ebc315b8"
- path: ".github/workflows/visual-diff.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma-vercel/visual-diff.yml.template"
version: "0.5.0-local"
installed_hash: "sha256:88270b1fa59aba99591ec094764dd367deed956bcb746ac6bb195241b3a7dae1"
- path: "docs/agent-context/README.md"
source: "skills/bootstrap-agent-context/templates/L1-context/agent-context-readme.md.template"
version: "0.5.0-local"
installed_hash: "sha256:095b9cc6a30327114c9ddfb4ff57a5fde76b213e12b1c5574a1f96205d60dbad"
- path: "lib/flags/index.js"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma-vercel/flags-index.ts.template"
version: "0.5.0-local"
installed_hash: "sha256:1a3cd1f900194eaf4ec86588dd1c3c2bff6a565e742061fc911abdd47bd5f3a5"
- path: "scripts/log-convoy-event.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/log-convoy-event.sh"
version: "0.5.0"
installed_hash: "sha256:cd0413691066a177b6b4e6164a9a0978c20a853ad60222ae833b5d53b255818d"
- path: "scripts/wt.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/wt.sh"
version: "0.5.0"
installed_hash: "sha256:2a4f44a159f80a8ea6fe53ac507c01a2f91a4e2118d997a98b051808ac35e9a5"
- path: "tests/smoke/app.smoke.spec.ts"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma-vercel/playwright-smoke.spec.ts.template"
version: "0.5.0"
installed_hash: "sha256:a62c10edb712a61f1cfece43705bfff75a5a66ad6bc8b53f7e69a43c3efb962c"

121
.convoys/README.md Normal file
View file

@ -0,0 +1,121 @@
# Convoys
A **convoy** is a multi-PR work-stream coordinated by an agent pipeline. One convoy = one feature, bug fix, or epic. Each convoy is a Markdown file in this directory plus an optional sub-directory of implementer briefs.
## File layout
```
.convoys/
├── README.md (this file)
├── <slug>.md (the convoy file — written by role-conductor)
└── <slug>/
├── brief-1-<kebab-title>.md (written by role-architect)
├── brief-2-<kebab-title>.md
└── ...
```
## Convoy file format
Frontmatter (set by `role-conductor`, then appended-to by other roles):
```yaml
---
name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence>
skip:
- <flag1>
status: open | in-progress | merged | shipped | abandoned
created: <YYYY-MM-DD>
---
```
Body sections (added in order by the pipeline roles):
1. `## Why` (Conductor)
2. `## Scope` (Conductor)
3. `## Roles invoked` (Conductor)
4. `## Todos` (Conductor → refined by Architect)
5. `## IA` (IA Architect)
6. `## UX` (UX Reviewer)
7. `## Architecture` (Architect)
After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers read only their brief, not the whole convoy.
## Skip flags
The Conductor sets `skip:` based on classification. These flags map to pipeline stages that no-op when set:
| Flag | Skips |
| --- | --- |
| `ia` | IA Architect |
| `ux` | UX Reviewer |
| `arch` | Architect |
| `test` | Component tests |
| `review` | Reviewer |
| `visual` | Visual diff |
| `a11y` | A11y auditor |
| `design` | Design-system auditor |
| `smoke` | Staging smoke |
| `qa` | Manual QA |
| `docs` | Doc Writer |
| `flag` | Flag rollout |
Never skipped (mandatory human gates): `plan-approval`, `pr-merge`, `prod-promote`.
## Status lifecycle
- `open` — Conductor created the convoy; no work started.
- `in-progress` — At least one brief has an open or merged PR.
- `merged` — All briefs merged to umbrella; release PR to develop pending.
- `shipped` — Release to main complete; flag rollout (if any) underway.
- `abandoned` — Convoy closed without shipping; reason in convoy body.
Update status by editing the convoy frontmatter as you progress.
## Adding a new convoy
1. Open Cursor in this repo.
2. Prompt: *"Start a new convoy: <one-paragraph idea>. Success = <metric>."*
3. The `role-conductor` subagent writes `.convoys/<slug>.md`.
4. Run subsequent roles in order per the convoy's `Roles invoked` list.
See `.cursor/agents/role-conductor.md` for the Conductor's full spec.
## Multitask + worktrees (Cursor 3.2+)
[Cursor 3.2 (Apr 24, 2026)](https://cursor.com/changelog/04-24-26) added `/multitask` async subagents and native worktree management in the Agents Window. The pipeline uses both:
**Audit fan-out** — after an implementer ships a PR draft:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
All three read the same diff and emit independent comments. Use group id `audit-<convoy>-<pr>` so analytics can compute wall-clock savings.
**Implementer fleet** — after architect's plan is approved (gate 1), if `slice_dependencies:` declares parallel-safe briefs (`depends_on: []`, disjoint `files:`):
```
/multitask role-implementer briefs 1, 2, 3
```
Use Cursor's Agents Window to create a worktree per brief — one click each. The legacy `scripts/wt.sh` is now a deprecation stub.
See the [multitask playbook](https://github.com/varutasu/agent-pipeline/blob/main/docs/multitask-playbook.md) for the full guardrail set.
## Self-analytics
Each L2 role appends one event to `.convoys/.metrics.jsonl` via `scripts/log-convoy-event.sh`. The file is gitignored by default — events stay local. To opt-in to commit team-shared metrics, remove `.convoys/.metrics.jsonl` from `.gitignore`.
Aggregate across repos and render a dashboard with the [agent-pipeline analytics scripts](https://github.com/varutasu/agent-pipeline/tree/main/analytics):
```bash
cd ~/code/agent-pipeline/analytics
npx tsx analyze-convoys.ts <repo-path> [<repo-path>...]
npx tsx render-dashboard.ts
open ~/agent-pipeline-data/dashboard.html
```
Schema: [`analytics/schemas/convoy-event.json`](https://github.com/varutasu/agent-pipeline/blob/main/analytics/schemas/convoy-event.json).

370
.convoys/bump-next-js.md Normal file
View file

@ -0,0 +1,370 @@
---
name: bump-next-js
classification: feature
success_metric: "npm install next@16.2.6 ships, Vercel deploys complete, no runtime regressions in dev or build."
skip:
- ia
- ux
- flag
status: open
created: 2026-05-22
---
# Convoy: bump-next-js
Closes P0 ship-blocker **#8** from `.convoys/ship-readiness.md`. Highest-priority convoy in the launch sequence — promoted to slot 0 because Vercel is currently refusing to deploy any branch (including `main`) until Next.js is bumped, which makes every downstream `preview-smoke` / `visual-diff` gate non-functional.
## Why
Vercel's platform-level security gate is blocking every deployment with `"Vulnerable version of Next.js detected, please update immediately"`. The lockfile currently resolves `next@15.4.3`; latest is `16.2.6`. The build itself completes (Vercel CLI confirms `Build Completed in /vercel/output [29s]`), but the deployment is rejected before going live.
Concrete impact, as of 2026-05-22:
- **The last successful deploy on `main` was 2025-08-01.** Production is stale.
- **Preview deployments are unavailable** on every PR. `preview-smoke.yml` and `visual-diff.yml` have nothing to point at, so they fail-quiet on every PR.
- **PR #1 (the bootstrap PR) cannot validate its own L3 visual gates** because of this.
This convoy unblocks the entire launch sequence. Until it ships, the other 13 convoys are running half-blind. Success looks like:
1. `package.json` declares `"next": "^16.2.6"` (or whatever the architect picks — see scope).
2. `package-lock.json` regenerated.
3. `npm run dev` boots without warnings about deprecated APIs.
4. `npm run build` exits 0 with no breaking-change errors.
5. A PR opened from a feature branch produces a **successful** Vercel preview deploy.
6. `preview-smoke` and `visual-diff` workflows have a live URL to hit (they'll still fail on missing `@playwright/test` until `adopt-vitest` lands, but the Vercel half is no longer broken).
7. CI green: lint passes (wrapper is in place from bootstrap), aggregate gate passes.
## Scope
**In:**
- Bump `next` from `15.4.3` to `16.2.6` in `package.json` + `package-lock.json`.
- Bump `eslint-config-next` from `15.4.2` to a matching `16.x` release to keep the lint config aligned with the framework.
- Audit Next.js 15 → 16 migration guide ([blog](https://nextjs.org/blog/next-16), [upgrade guide](https://nextjs.org/docs/app/building-your-application/upgrading)) and identify which surfaces in `tcg-vault` are affected. Educated guess at affected paths (validate during architect):
- `next.config.js` — the `images.domains` field has been deprecated for several major versions; if Next 16 drops it, migrate to `images.remotePatterns`.
- `next/image` usage across `pages/cards.js`, `pages/card/[id].js`, `components/CollectionSelectionModal.js`, `components/ManaSymbols.js`, `components/UploadImageModal.js` — verify props are still supported.
- Pages Router specifics — Pages Router is intentionally more stable than App Router across major bumps, but `getServerSideProps` / `getStaticProps` semantics may have edge-case changes.
- API routes — `req` / `res` API stays stable in Pages Router; should be a no-op surface.
- Middleware — `tcg-vault` has no `middleware.js` currently; nothing to migrate.
- Update `AGENTS.md` "Tech stack quick reference" to bump the Next.js version string.
- Validate via local `npm run build`, then push to confirm Vercel preview deploys successfully.
**Out (deferred to their own convoys):**
- **React 18 → 19 upgrade.** `next@16` peer-deps accept `react@^18.2.0 || ^19.0.0`. Current `react@18.3.1` is in range. A React 19 bump is its own convoy (`bump-react`) because of compiler / Suspense / `use()` API changes.
- **App Router migration.** `tcg-vault` is on Pages Router. Migrating to App Router is a multi-month effort and outside this convoy.
- **Test runner adoption** (`adopt-vitest` / `adopt-playwright-smoke`) — those convoys remain queued.
- **`.eslintrc.json` rule tuning** — the bootstrap added a stub extending `next/core-web-vitals`. If `eslint-config-next@16` ships new rules that surface additional errors, defer the cleanup to `fix-lint-baseline`.
**Hard "do not touch" in this convoy:**
- No auth code (`lib/permission-middleware.js`, `pages/api/auth/`, `pages/api/auth-utils.js`) — that's `fix-auth-bypass`.
- No DB code.
- No new features or UI changes beyond what's strictly required to keep existing pages rendering after the bump.
- No CODEOWNERS / workflow / convoy file edits.
- No feature flags. The bump ships unflagged.
## Roles invoked
Per `feature` classification with custom skips (`ia, ux, flag`):
1. **role-architect** — produces a slice plan. Reads the Next 16 migration guide, lists every breaking change that touches `tcg-vault`, decides which need code changes vs. configuration changes vs. no-ops. Output: 13 briefs under `.convoys/bump-next-js/brief-N-*.md`. Likely shape:
- Brief 1: the bump itself (package.json + lockfile + any required `next.config.js` migration).
- Brief 2 (if needed): code changes for any deprecated APIs (e.g. `<Image>` prop rename).
- Brief 3 (if needed): visual-diff baseline refresh if rendering changed.
2. **role-implementer** — single-writer flow. The bump itself is one file change + lockfile; can't be meaningfully parallelized.
3. **Audit fan-out** (`/multitask`, group id `audit-bump-next-js-<pr>`) — runs in parallel after the PR is drafted:
- **role-reviewer** — correctness, regression risk
- **role-design-system-auditor** — verify CSS / theming / token usage still renders correctly
- **role-a11y-auditor** — verify accessibility didn't regress (Next.js 16 may change focus-management defaults)
4. **role-doc-writer** — last. Updates `AGENTS.md` "Tech stack" section. Adds an entry to a CHANGELOG if one is started here (it'll be backfilled separately in `launch-polish`).
## Todos
High-level checklist for the architect to refine into briefs:
- [ ] **Brief 1 — Migration audit.** Read the [Next.js 16 release notes](https://nextjs.org/blog/next-16) and [upgrade guide](https://nextjs.org/docs/app/building-your-application/upgrading). Produce a short table: deprecated API → file(s) that use it → migration step. Specifically check: `images.domains` deprecation, `next/font` changes, `next/image` prop changes, any default-runtime changes (edge vs node).
- [ ] **Brief 2 — Bump + lockfile.** `npm install next@16.2.6 eslint-config-next@^16`. Commit `package.json` + `package-lock.json`. Verify `npm ls next` shows the new version.
- [ ] **Brief 3 — Verify build + dev locally.** `npm run build` must exit 0 with no breaking-change errors. `npm run dev` must boot without deprecation warnings on the routes we ship today. If errors surface, this is where they get fixed.
- [ ] **Brief 4 — Vercel preview deploy.** Push the branch and confirm the Vercel deploy completes successfully (status moves from `pending``success`, not `Error`). Capture the preview URL in the PR description.
- [ ] **Brief 5 — Visual diff baseline.** If `preview-smoke.yml` / `visual-diff.yml` aren't installed yet (they need `@playwright/test`), this brief is informational — flag any obvious visual changes to the reviewer + design-system-auditor. Once `adopt-playwright-smoke` lands, this becomes a real verification step.
- [ ] **Doc-writer pass.** Update `AGENTS.md` tech-stack line. Note the bump in the bootstrap PR's "Notes for reviewer" or, if PR #1 has merged by then, open a small standalone docs PR.
## Hand-off
**Next role: `role-architect`** (IA + UX are skipped; routing straight to Architect).
To run it in a new chat, paste:
> *"Run role-architect on convoy `bump-next-js`. Read `.convoys/bump-next-js.md` for scope and todos, then read the Next.js 15 → 16 upgrade guide and produce a slice plan. Output briefs to `.convoys/bump-next-js/brief-N-*.md`. Mark any briefs that are parallel-safe (probably none — this is mostly a single-writer flow except the audit fan-out). Be conservative about scope creep: if the migration guide flags an API not used in `tcg-vault`, note it in the brief but don't add a 'while we're here' fix."*
After architect publishes the brief(s), the user runs `role-implementer` serially. Once the PR is drafted, the user uses Cursor 3.2 `/multitask` to dispatch the audit cohort (`reviewer + design-system-auditor + a11y-auditor`) in parallel under group id `audit-bump-next-js-<pr>`.
Conductor exits here.
## Architecture
_Produced by `role-architect` on 2026-05-22 against Next.js 16.2.6 (latest stable; verified via `npm view next version`). Updated 2026-05-23 after four gate-1 scope changes (A, B, C, D — see Decisions log below):_
- _A: scope expanded to include the ESLint v8 → v9 + flat-config migration so `eslint-config-next` can move to `^16` matching `next`._
- _B: pivoted from ESLint v9 to v10 (then-`latest`) after gate-1 re-review of risk R14._
- _C: added `typescript@^5.9.3` as a devDep after an implementer escalation surfaced that `eslint-config-next@16`'s `peerDependenciesMeta.typescript.optional: true` annotation does not make `typescript` runtime-optional._
- _D: reverted the v10 pivot back to v9.39.4 after a pass-2 implementer escalation showed Risk R15 firing empirically (`TypeError: scopeManager.addGlobals is not a function` from `@typescript-eslint/scope-manager@8.59.4` predating v10 GA). v10 deferred to the upstream-blocked `bump-eslint-10` follow-up convoy._
### File plan
| File | Action | Purpose |
| --- | --- | --- |
| `package.json` | modified | Bump `dependencies.next` from `^15.4.2` to `^16.2.6`. Bump `devDependencies.eslint` from `^8` to `^9.39.4` (npm's `maintenance` dist-tag; per Decisions log entry D, reverted from the v10 pin set under entry B after R15 fired empirically). Bump `devDependencies.eslint-config-next` from `15.4.2` to `^16.2.6` to match `next` — peer-dep `eslint: >=9.0.0` accepts v9.39.4 trivially. **Add `devDependencies.typescript: "^5.9.3"`** (per Decisions log entry C — `eslint-config-next@16` bundles `typescript-eslint`, which hard-requires `typescript` at module load under both v9 and v10; the `peerDependenciesMeta.typescript.optional: true` flag only suppresses npm's install-time warning, not the runtime require). Replace `scripts.lint` from `next lint` to `eslint .` (Next 16 removed the `next lint` command). React, react-dom, and all other packages stay unchanged. |
| `package-lock.json` | modified | Regenerated by `npm install`. Reflects the new `next@16.2.6`, `eslint@^9.39.4`, `eslint-config-next@^16.2.6`, and `typescript@^5.9.3` resolutions. Under Decision D the lockfile stays on the v9 dep-tree (`@eslint/eslintrc` is still a v9 transitive dep; the v10 dep-tree changes that would have removed it are deferred to the queued `bump-eslint-10` follow-up convoy). The new `typescript` subtree is small — `typescript` itself has no `dependencies` and no `peerDependencies`. Do not hand-edit. |
| `next.config.js` | modified | Migrate `images.domains: [...]` (deprecated in 16, deprecation warning at startup) to `images.remotePatterns: [...]`. Three patterns, one per CDN currently in `images.domains`. |
| `.eslintrc.json` | **deleted** | The 40-byte legacy stub (`{"extends": "next/core-web-vitals"}`) is replaced by `eslint.config.mjs` because `eslint-config-next@16` only supports flat config. Leaving both files in place would be a footgun. |
| `eslint.config.mjs` | **new** | Flat-config replacement for `.eslintrc.json`. Reproduces the prior `next/core-web-vitals` extends behavior using the verbatim shape from the official Next.js docs (`defineConfig([...nextVitals, globalIgnores([...])])`). `globalIgnores` covers the no-go-zone paths the user specified at gate 1 plus `eslint-config-next`'s documented defaults. |
Doc-writer's `AGENTS.md` "Tech stack" string update is a separate PR by `role-doc-writer` after this one merges (per the convoy's Roles list).
### API surface
**No API changes.** This convoy does not touch `pages/api/**`. The Next.js 16 upgrade guide does not change the Pages-Router `req`/`res` handler signature; `tcg-vault`'s ~30 API handlers all use the legacy `(req, res) => { ... }` shape and continue to work unchanged.
### Schema diff
**No schema changes.** This convoy does not touch the database. Neon Postgres + `scripts/setup-neon-db.js` are out of scope.
### Test plan
`tcg-vault` has no automated test runner installed yet (vitest + Playwright adoption is tracked under `adopt-vitest` and `adopt-playwright-smoke` convoys). For this convoy:
- **Manual smoke per `TESTING_GUIDE.md`** is the verification mechanism. Specifically: home (`/`), login (`/login`), signup (`/signup`), browse (`/cards`), and `/collections` must render without runtime errors after the bump.
- **`npm run build` exiting 0** is the integration test for the Turbopack default-bundler change. No custom webpack config exists in `next.config.js`, so Turbopack should "just work."
- **`npm run lint` running to completion** (regardless of the error count) is the integration test for the ESLint v8 → v9 + flat-config migration (per Decision D — Decision B's pivot to v10 was reverted after R15 fired empirically). The pre-existing baseline of ~100 errors is expected to shift modestly under v9 due to plugin major bumps (`eslint-plugin-react-hooks` v5 → v7, `@next/eslint-plugin-next` 15 → 16) but **not** to shift the way v10 would have (no new `eslint:recommended` rules, no JSX reference tracking, no `no-shadow-restricted-names.reportGlobalThis: true` default — those land later under `bump-eslint-10`). CI's `|| true` wrapper continues to tolerate any non-zero exit. **Counting baseline drift is `fix-lint-baseline`'s job, not this convoy's.** **If `npm run lint` does not run to completion under v9.39.4** — e.g. it crashes with a `TypeError` — see Brief 1's failure-mode classifier under "Local verification." Decision D's expectation is that R15's empirical signature (`scopeManager.addGlobals is not a function`) does NOT recur on v9 because v9 doesn't call `addGlobals`. If a different `TypeError` fires on v9, escalate rather than patching transitive deps.
- **Vercel preview deploy reaching Success state** is the end-to-end integration test. The convoy's `success_metric` ("npm install next@16.2.6 ships, Vercel deploys complete, no runtime regressions in dev or build") is exactly this.
- **Audit fan-out (`/multitask`, group id `audit-bump-next-js-<pr>`)** is the qualitative gate: `role-reviewer` for correctness, `role-design-system-auditor` for token rendering, `role-a11y-auditor` for focus / scroll-behavior regression. They run AFTER the PR is drafted, not as part of this brief.
- Once `adopt-vitest` lands, retrofit a smoke test for `next.config.js` parsing and one for `<img>` (or future `<Image>`) rendering against a fixture page.
### Risk list
- **R1: Turbopack-by-default may surface unexpected build/runtime differences vs webpack.** Per gate-1 decision: accept the default. tcg-vault has no `webpack:` block in `next.config.js`, no custom loaders/aliases, no Sass tilde imports, no `resolve.fallback` workarounds. Likelihood of regression: low. **Fallback per command:** `next build --webpack` and `next dev --webpack`. **If a regression appears, the implementer should reproduce on both bundlers** (run the failing flow once with the default, once with `--webpack`) **before deciding whether to revert the bump or pin the script to webpack.** Capture the reproduction in the PR description for `role-reviewer` to triage. Do not pre-emptively add `--webpack` to the scripts.
- **R2 — RESOLVED at gate 1, via the A → B → D path.** Originally: "`eslint-config-next` cannot be bumped to `^16` in this convoy." Gate-1 decision A expanded scope to include the ESLint v8 → v9 + flat-config migration. Decision B pivoted from v9 to v10. Decision D reverted v10 → v9.39.4 after R15 fired empirically on the implementer's pass-2 lint run. **Final pins:** `eslint@^9.39.4`, `eslint-config-next@^16.2.6`, `typescript@^5.9.3`, `.eslintrc.json` deleted, `eslint.config.mjs` created. See R12, R13, R14, R15 below for the residual + reinstated risks. The deferred `migrate-to-eslint-flat-config` convoy is **closed before opening** — its work has been folded in. The `bump-eslint-10` follow-up convoy is **queued as upstream-blocked** — see "Follow-up convoys queued" section.
- **R3: `next lint` removal hard-breaks `npm run lint`.** Without the `scripts.lint` change, both local devs and CI's `npm run lint --if-present` job would invoke a removed command. Mitigation: change script to `eslint .`. CI's existing `|| true` wrapper continues to tolerate the pre-existing lint baseline (~100 errors, tracked under `fix-lint-baseline`).
- **R4: `images.domains` is in `next.config.js` but `next/image` isn't actually used.** Strictly speaking, the migration is preemptive — silences the deprecation warning but adds no functional change. Acceptable: keeps the config valid for the eventual `next/image` adoption. Don't delete the block; that would force re-adding it later.
- **R5: `images.minimumCacheTTL` default changed from 60s to 4h.** Behavior change. Not impactful in `tcg-vault` because `next/image` isn't used. No mitigation required; flag here only so future readers don't re-investigate.
- **R6: Vercel deploy might fail for an unrelated reason.** The convoy's premise is that the platform-level "Vulnerable version" gate is the sole blocker. If the build itself fails on 16 (e.g. an undocumented Turbopack edge case), the fix lands in this brief. If the failure is environmental (env vars, build settings), escalate — that's a different convoy.
- **R7: React 18 stays — intentional.** `next@16` peer-dep accepts `react ^18.2.0 || ^19.0.0`. Current `18.3.1` is in range. **Do not bump React in this convoy.** React 19 has compiler / Suspense / `use()` API changes and is its own convoy (`bump-react`).
- **R8: TypeScript >=5.1.0 required by Next 16.** Partially applicable. **`tcg-vault` source code remains plain JavaScript** — no `tsconfig.json`, no `.ts`/`.tsx` files, no source migration in this convoy. **However, per Decision C (2026-05-23), `typescript@^5.9.3` is now installed as a devDep** because `eslint-config-next@16`'s bundled `typescript-eslint` chain hard-`require`s it at module load. The original parenthetical claim — "`eslint-config-next@16` lists `typescript` as an optional peer (`peerDependenciesMeta.typescript.optional: true`) so JS-only consumers are fine" — was wrong: that flag only suppresses npm's install-time warning; the transitive `@typescript-eslint/typescript-estree@8.59.4` (a regular `dependency`, not a peer) does an unconditional `require('typescript')` at module load. See R16 for the full devDep impact analysis.
- **R9: Node.js floor — DEFANGED under Decision D.** Originally elevated under Decision B because ESLint v10 raised the floor to `^20.19.0 || ^22.13.0 || >=24`. **Under Decision D's v9.39.4 pin**, the ESLint floor reverts to `^18.18.0 || ^20.9.0 || >=21.1.0` (Next 16 also requires `>=20.9.0` — the same floor). CI's `setup-node@v4` `node-version: '20'`, local `node@22.14.0`, and Vercel's default Node 22 all satisfy with margin to spare. The "moving target on `node-version: '20'`" concern from Decision B is inert under v9. **The constraint will reactivate when `bump-eslint-10` lands**; the queued follow-up convoy should pick up the CI pin question (`node-version: '20.19'` or `'lts/iron'`) at that point.
- **R10: `next dev` and `next build` now use separate output dirs (`.next/dev/` vs `.next/`).** `.gitignore` line 28 has `/.next/`, which is a directory rule that covers both subdirs. No `.gitignore` change needed.
- **R11: Convoy file's audit list (line 45) is wrong about `next/image` usage.** Pages listed (`pages/cards.js`, `pages/card/[id].js`, etc.) use plain `<img>` tags, not `<Image>`. Architect verified via `rg "from ['\"]next/image['\"]"` — zero hits in `pages/`, `components/`, `lib/`. Surface this to the convoy author so future planning is not based on the same assumption.
- **R12 (post-gate-1 expansion; revised under Decision D): ESLint flat-config migration + plugin major bumps will shift the lint baseline modestly.** Drivers under v9.39.4: `eslint-config-next@16.2.6` bundles `eslint-plugin-react-hooks@^7` (vs v5) and `@next/eslint-plugin-next@16` (vs 15.4.2). **The v10-specific drivers from Decision B's wording are deferred to the queued `bump-eslint-10` follow-up** (the three new `eslint:recommended` rules, JSX reference tracking, `no-shadow-restricted-names.reportGlobalThis: true` default). `eslint-env` comments would be errors under v10 — we have zero (`rg "eslint-env"` returned zero hits, ✓), so the `bump-eslint-10` follow-up will not snag here either. **The ~100-error baseline is approximate and will move modestly under v9, more substantially when v10 lands.** CI's `|| true` wrapper tolerates any non-zero exit, so this is non-blocking either way. **Do not "fix while we're here."** `fix-lint-baseline` will reconcile against whichever baseline is current.
- **R13: Native flat-config import path is verbatim from Next.js docs — no `FlatCompat` shim added.** Boot-the-brief verified by extracting the published tarball that `eslint-config-next/core-web-vitals` exports a flat-config array (`module.exports = config`). Under Decision D's v9 pin, `@eslint/eslintrc` is still part of v9's own dep tree (v10 dropped it), so the lockfile retains it as a transitive dep — but we still don't import `FlatCompat` from it. If for any reason the native flat-config export resolution fails at install time (e.g. a transitive dep mismatch), the implementer should NOT swap in `@eslint/eslintrc`'s `FlatCompat` — instead, raise it in the PR description and the architect will revisit.
- **R14 — REINSTATED under Decision D (2026-05-23).** ESLint v10.4.0 is the current `latest` dist-tag; this convoy pins `eslint@^9.39.4` (the `maintenance` dist-tag) per Decision D until upstream `eslint-config-next` ships a release that bundles a v10-tested `typescript-eslint`. **Tracked under follow-up convoy `bump-eslint-10` (currently upstream-blocked)** — see "Follow-up convoys queued" section. The previous "RESOLVED at gate 1 (Decision B)" framing was correct given Boot-the-brief evidence at the time; Decision D reverses it specifically because empirical lint runs surfaced R15 firing. **Cost of pinning to maintenance:** small. v9.39.4 still receives security backports if any are needed during the window before `bump-eslint-10` lands; the v9 → v10 jump is a single-line `package.json` edit when prerequisites are met (no flat-config edits required — same `defineConfig` + `globalIgnores` shape works on both majors).
- **R15 — FIRED EMPIRICALLY (pass-2 implementer run, 2026-05-23); RESOLVED BY DECISION D.** Originally framed as: "`eslint-config-next@16.2.6`'s bundled plugin set was published before ESLint v10 (Oct 2025 vs Feb 2026); v10 runtime compatibility is statically unprovable." **What actually fired** was a different (and worse) failure mode than the originally feared `context.getCwd()` / `SourceCode#getJSDocComment()` deprecated-API removals:
- **Crash signature:** `TypeError: scopeManager.addGlobals is not a function`
- **Call site:** ESLint v10's `lib/source-code/source-code.js:221` calls `scopeManager.addGlobals(...)` as part of v10's redesigned global-ingestion path.
- **Missing-method site:** `@typescript-eslint/scope-manager@8.59.4` (transitive dep of `typescript-eslint@8.59.4`, which `eslint-config-next@16.2.6` bundles as a regular `dependency`) does not implement `addGlobals` on its `ScopeManager` class. The method is a v10-introduced extension; v9 used a different ingestion path that `typescript-eslint@8.x` was authored against.
- **Why bundled-plugin set didn't help:** `@typescript-eslint/scope-manager@8.x` was published Oct/Nov 2025; v10 GA was 2026-02-06. `typescript-eslint` has not yet shipped a v10-tested release. The peer-dep range `eslint: >=9.0.0` is technically satisfied by v10, but the runtime compatibility was not.
- **Resolution (Decision D):** revert `eslint` to `^9.39.4`. The same `typescript-eslint@8.59.4` works correctly on v9 because v9 doesn't call `addGlobals`. `typescript@^5.9.3` (Decision C) is retained — that install was confirmed correct on pass 2 and is required under both v9 and v10.
- **R15 stays in the convoy's risk list as FIRED-RESOLVED** so the historical record is preserved and so the queued `bump-eslint-10` follow-up convoy inherits the diagnostic verbatim. The originally feared deprecated-API removals (`context.getCwd()`, etc.) are still live risks for the eventual v10 cutover, but they did not fire on pass 2 — `addGlobals` fired first.
- **R16 (new, post-gate-1 Decision C; status unchanged under Decision D): Adding `typescript` as a devDep brings `typescript@^5.x` and its tooling into the dep tree.** This is universally how JS-only Next.js projects handle `eslint-config-next@16` — the package's `typescript-eslint` transitive dep (specifically `@typescript-eslint/typescript-estree@8.59.4`'s `dist/convert.js:40`) hard-requires `typescript` at runtime despite being flagged `peerDependenciesMeta.optional: true` at the `eslint-config-next` wrapper level (the `optional` annotation only suppresses npm's install-time warning, not the runtime require). **Confirmed correct under Decision D's v9 pin** — pass-2 implementer evidence shows the `typescript` install resolved the original `Cannot find module 'typescript'` crash; the residual `addGlobals` crash was a different failure mode (R15) and is the reason for the v9 revert. No downstream impact expected: `typescript` only runs when lint runs (the JS source code is unchanged, no `tsconfig.json` is created, no `.js` files are renamed); `fix-lint-baseline` and `adopt-vitest` convoys will not be affected. **Engines:** `typescript@5.9.3` requires `node >= 14.17`, well below ESLint v9's `^18.18.0` floor (and v10's `^20.19.0` floor when `bump-eslint-10` lands) — no new Node constraint introduced. **Lockfile impact:** small — `typescript` has no `dependencies` and no `peerDependencies`. **Verified runtime require evidence:** see Brief 1's Boot-the-brief finding #17 for the verbatim 9-site grep of `require('typescript')` in the published `typescript-estree@8.59.4` tarball, all unconditional (no `try/catch`, no dynamic import, no `require.resolve` guard).
### Decomposition
| Brief # | Title | Files | Depends on | Estimated PR size |
| --- | --- | --- | --- | --- |
| 1 | Bump Next.js to 16.2.6 + migrate `next.config.js`, ESLint flat config, and lint script | `package.json` (mod — `next`, `eslint`, `eslint-config-next` bumps + new `typescript` devDep per Decision C), `package-lock.json` (mod), `next.config.js` (mod), `eslint.config.mjs` (new), `.eslintrc.json` (deleted) | _(none)_ | 5 files touched (3 mod, 1 new, 1 deleted), lockfile regen (large auto-diff). True non-lockfile diff: ~31 LOC (~12 of which is the new `eslint.config.mjs`; +1 LOC for the `typescript` devDep line in `package.json`). |
**Still one brief, even after four gate-1 scope changes (A, B, C, D).** Both the Next bump and the ESLint migration touch `package.json`, so they cannot run in parallel anyway — keeping them in one brief gives reviewers one PR, one Vercel preview, and one revert boundary if anything regresses. The cumulative expansion adds ~21 LOC (delete a 40-byte file, add a ~12-LOC `eslint.config.mjs`, three devDep changes in `package.json` — two version bumps for `eslint`/`eslint-config-next` and one new line for `typescript@^5.9.3`). Decision D does not change the LOC count — it re-pins an existing line (`devDependencies.eslint`) from `^10.4.0` back to `^9.39.4`, no addition or deletion. Total non-lockfile diff stays well under the 400-LOC guideline. Decisions C and D do not change the brief count, the brief's `files:` set, or the `slice_dependencies` graph — `package.json` and `package-lock.json` were already in scope from the start. Doc-writer's `AGENTS.md` pass remains a separate PR by `role-doc-writer` per the convoy's Roles list.
The audit fan-out (`role-reviewer` + `role-design-system-auditor` + `role-a11y-auditor`) is **parallel via `/multitask`**, but that's a downstream concern triggered by the conductor after the PR is drafted — not part of the implementer decomposition.
### Slice dependencies (multitask-ready)
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- package.json
- package-lock.json
- next.config.js
- eslint.config.mjs
deletes:
- .eslintrc.json
```
Single brief, no parallelization opportunity at the implementer stage. The conductor should dispatch `role-implementer` serially (no `/multitask` fan-out for the implementer phase). The audit-cohort fan-out happens later, after PR draft, under group id `audit-bump-next-js-<pr>`.
## Decisions (post-IA round)
### A — 2026-05-23: Expand convoy scope to include ESLint v8 → v9 + flat-config migration
**Context.** During the architect's initial Boot-the-brief check, two findings landed at human gate 1:
1. `eslint-config-next@16.2.6` requires `eslint >= 9.0.0` (flat config). The convoy file (line 42) prescribed bumping `eslint-config-next` to `^16` "matching next" but didn't account for this peer-dep cliff. The architect's first-pass plan pinned `eslint-config-next@15.4.2` and flagged the deviation.
2. `next lint` was removed in Next 16. `package.json`'s `lint` script and CI's `npm run lint` both invoke a removed command in 16.
**Decision.** Expand this convoy to include the ESLint v9 + flat-config migration, rather than spinning out a separate `migrate-to-eslint-flat-config` convoy. Rationale: both the Next bump and the ESLint migration touch `package.json`, so they cannot ship in parallel anyway; one PR gives reviewers a single revert boundary; the expansion adds only ~20 LOC of non-lockfile diff (delete `.eslintrc.json`, add `eslint.config.mjs`, two devDep version bumps); and `eslint-config-next@16` ships native flat-config exports so no `FlatCompat` shim or `@eslint/eslintrc` install is needed.
**Specific changes baked into Brief 1:**
- Bump `devDependencies.eslint` from `^8` to `^9.39.4` (latest 9.x; ESLint v10 was released between convoy authoring and now — see Boot-the-brief #5 — but per this decision we stay on 9.x).
- Bump `devDependencies.eslint-config-next` from `15.4.2` to `^16.2.6`.
- Change `scripts.lint` from `"next lint"` to `"eslint ."`.
- Delete `.eslintrc.json` (40-byte stub: `{"extends": "next/core-web-vitals"}`).
- Add `eslint.config.mjs` using the verbatim shape from the [official Next.js docs](https://nextjs.org/docs/app/api-reference/config/eslint): `defineConfig([...nextVitals, globalIgnores([...])])`. Imports come from `eslint/config` (built-in helpers since 9.21.0) and `eslint-config-next/core-web-vitals`.
- `globalIgnores` covers `.next/**`, `node_modules/**`, `out/**`, `build/**`, `next-env.d.ts`, and `scripts/migrations/**` per gate-1 instruction.
**Out-of-scope (still deferred):**
- Fixing the ~100-error pre-existing lint baseline. Stays under `fix-lint-baseline`. CI's `npm run lint || true` wrapper continues to tolerate non-zero exit; the baseline number will shift with the v9 plugin upgrades but counting that drift is `fix-lint-baseline`'s job.
- Bumping ESLint to v10. Surfaced as Risk R14; revisit in a later `bump-eslint-10` convoy if desired.
- Bumping React 18 → 19. Stays under `bump-react`.
- App Router migration, test-runner adoption, auth fixes, schema migrations — all unchanged from the original convoy scope.
**Canonical authority.** `tcg-vault` does not maintain `docs/04-architecture/*.md` files, so this Decisions entry IS the canonical authority. Decision recorded in chat on 2026-05-23 between user and `role-architect`. Boot-the-brief recheck performed against this decision before publishing the revised Brief 1.
**Consequences for downstream roles.**
- `role-implementer`: must run `npm install next@^16.2.6 eslint@^9.39.4 eslint-config-next@^16.2.6` (the three explicit version pins), then delete `.eslintrc.json`, write `eslint.config.mjs` per the verbatim shape in Brief 1, and update `package.json`'s `scripts.lint`. No mid-flight scope decisions.
- `role-reviewer`: includes the ESLint config change in correctness review. Verify `npm run lint` runs (regardless of error count); verify the lockfile diff is consistent with the three version pins.
- `role-design-system-auditor` and `role-a11y-auditor`: unchanged. The lint config doesn't affect render output.
- `role-doc-writer`: still updates `AGENTS.md` "Tech stack" string (Next.js 15 → 16) in a separate PR. May optionally also update the line that says "JavaScript (not TypeScript)" remains accurate; no change needed there.
> **Superseded by Decision B (2026-05-23, same day).** The implementer command above changed from `eslint@^9.39.4` to `eslint@^10.4.0`. See entry B below for details.
### B — 2026-05-23: Pivot ESLint pin from v9.x to v10.x
**Context.** Decision A (above, same day) expanded scope to include the ESLint v8 → v9 + flat-config migration, with `eslint` pinned to `^9.39.4`. During the architect's Boot-the-brief recheck of A, finding #5 surfaced that **ESLint v10.4.0 had been released to the `latest` dist-tag on 2026-02-06** — between when this convoy was authored (2026-05-22) and when gate 1 was reached (2026-05-23). v9.39.4 had moved to the `maintenance` tag. The architect surfaced this as Risk R14 with a "stay conservative on v9" recommendation. On gate-1 re-review, the user pivoted to v10 to avoid a back-to-back `bump-eslint-10` convoy.
**Decision.** Pin `devDependencies.eslint` to `^10.4.0` (current `latest`) instead of `^9.39.4`. All other pins from Decision A stand: `next@^16.2.6`, `eslint-config-next@^16.2.6`, `.eslintrc.json` deleted, `eslint.config.mjs` created with the same verbatim shape (no v10-specific signature change in `defineConfig` or `globalIgnores`).
**Boot-the-brief recheck against v10 (no blocker found):**
1. **Peer-dep compatibility.** `npm view eslint-config-next@16.2.6 peerDependencies` returns `{"eslint": ">=9.0.0", ...}` with **no `<10` upper bound**. v10 is accepted.
2. **Node engine compatibility.** `eslint@10.4.0` engines: `node ^20.19.0 || ^22.13.0 || >=24` (tighter floor than v9's `^18.18.0 || ^20.9.0 || >=21.1.0`). CI's `setup-node@v4` with `node-version: '20'` resolves to latest 20.x ≥ 20.19; local `node@22.14.0` is in `^22.13.0`; Vercel default Node 22 is ≥ 22.13. All ✓. Residual concern (CI's "latest 20.x" is a moving target) is documented as Risk R9; pinning CI to `node-version: '20.19'` would eliminate it but is out of scope per the convoy's "Hard do not touch" list.
3. **`eslint/config` exports retained.** Extracted `eslint@10.4.0` tarball, opened `lib/config-api.js`: still re-exports `defineConfig` and `globalIgnores` from `@eslint/config-helpers`. The brief's verbatim shape is unchanged.
4. **`eslint-env` comments are errors in v10.** `rg "eslint-env"` returned zero hits in `tcg-vault` source. ✓
5. **No App-Router-only or Cache-Components surfaces affected.** v10's `eslint:recommended` updates, JSX reference tracking, and `no-shadow-restricted-names.reportGlobalThis: true` will shift the lint baseline more than v9 would have, but that's `fix-lint-baseline`'s problem (Risk R12, expanded).
**The one new risk v10 surfaces (R15 in the convoy file's Risk list):** `eslint-config-next@16.2.6` was published before ESLint v10. Its bundled plugin set (`@next/eslint-plugin-next@16.2.6`, `eslint-plugin-react@^7.37.0`, `eslint-plugin-react-hooks@^7.0.0`, `eslint-plugin-import@^2.32.0`, `eslint-plugin-jsx-a11y@^6.10.0`, `typescript-eslint@^8.46.0`) was not statically vetted against v10. If any plugin uses a v9-deprecated API that v10 removed (`context.getCwd()`, `SourceCode#getJSDocComment()`, etc.), `npm run lint` will throw `TypeError`. **Acceptance criterion: `npm run lint` runs to completion.** If it crashes, the implementer escalates and we revert to v9 (one-line change). Cost of being wrong: small.
**Out-of-scope (still deferred):**
- All items deferred under Decision A remain deferred.
- CI workflow changes (e.g. pinning `node-version: '20.19'` for ESLint v10's stricter floor) — see Risk R9 residual concern. Pickup point: next CI-touching convoy (`adopt-vitest`).
- Any `bump-eslint-10` convoy is now **closed before opening** — its work is folded into this one.
**Canonical authority.** Same as Decision A — this Decisions entry IS the canonical authority. Decision recorded in chat on 2026-05-23 between user and `role-architect`, immediately after Decision A's gate-1 review surfaced finding R14.
**Updated consequences for downstream roles** (delta from Decision A):
- `role-implementer`: command becomes `npm install next@^16.2.6 eslint@^10.4.0 eslint-config-next@^16.2.6`. The `eslint.config.mjs` shape is unchanged. New explicit acceptance check: `npm run lint` running to completion (escalate on `TypeError`, do not patch transitive deps).
- `role-reviewer`: lockfile diff will additionally show `@eslint/eslintrc` being removed from the dep tree (v10 dropped it). Lint baseline will shift more than under v9; `|| true` wrapper still tolerates.
- `role-design-system-auditor`, `role-a11y-auditor`, `role-doc-writer`: unchanged from Decision A.
### C — 2026-05-23: Add `typescript` as a devDep (narrow scope expansion in response to implementer escalation)
**Context.** After Decisions A and B were applied, `role-implementer` ran the migration locally and `npm run lint` immediately crashed with `Cannot find module 'typescript'` during config load — before any rule executed. The implementer escalated. Root-cause diagnosis: `eslint-config-next/core-web-vitals``typescript-eslint@^8.46.0``@typescript-eslint/typescript-estree@8.59.4` does an unconditional `require('typescript')` at module load (verified after the fact by extracting the published `typescript-estree` tarball — `dist/convert.js:40` and 8 other sites are top-level `require('typescript')` calls, none gated on `try/catch` or `require.resolve`). The `peerDependenciesMeta.typescript.optional: true` annotation in `eslint-config-next@16.2.6`'s `package.json` only suppresses npm's install-time peer-dep warning; it does NOT make `typescript` runtime-optional. **The architect's Boot-the-brief finding #8 misread this annotation** and stated "tcg-vault is JS-only, no `typescript` install needed." That assumption was wrong, and the implementer caught it on first run.
This is **NOT** a manifestation of Risk R15 (no `TypeError` on a deprecated v9 API; the crash happened before any rule loaded). Reverting to ESLint v9 would not have fixed it — the same `typescript-eslint` chain ships with `eslint-config-next@16` regardless of the ESLint major version.
**The decision.** User chose **option (a) — add `typescript` as a devDep** at the gate. One-line scope expansion, ~minimal-risk:
- `package.json` adds `devDependencies.typescript: "^5.9.3"`.
- `package-lock.json` regenerates accordingly. The new `typescript` subtree is small (TypeScript itself has no `dependencies` and no `peerDependencies`).
- Pin choice: `^5.9.3`. **Note:** `npm view typescript@latest version` returns `6.0.3` (TypeScript 6 is the current `latest` major, contrary to the gate's parenthetical claim that 5 was latest). Latest 5.x is `5.9.3`. Two reasons to pin `^5.9.3` and defer v6: (1) honor the literal gate-1 instruction (`^5`); (2) `typescript-eslint@8.59.4`'s peer range is `>=4.8.4 <6.1.0` — strictly, `typescript@6.0.3` is in range, but `typescript-eslint@8.x` was published before TS 6 GA and has not advertised explicit v6 support, so staying inside the well-trodden 5.x range is safer until a future convoy bumps `typescript-eslint`. `^5.9.3` resolves to the latest 5.x patch.
- Pin range scope: full SemVer caret (`^5.9.3`), matching the convention used by `next` (`^15.4.2` → `^16.2.6`) and `react` (`^18.3.1`) elsewhere in `package.json`.
- No new files. No `tsconfig.json`. No `.js``.ts` migration. The brief's `files:` set is unchanged (`package.json` and `package-lock.json` were already in scope as modifications). The `slice_dependencies` graph is unchanged.
- No `eslint.config.mjs` change. The flat-config shape is independent of whether `typescript` is installed.
**Why option b (replace `eslint-config-next` with a JS-only ESLint preset) was dismissed.** `eslint-config-next@16` does not ship a JS-only entry point. Its `core-web-vitals` export bundles `typescript-eslint` as a regular dependency (not a peer), so consumers cannot opt out without forking the package or reimplementing the rule set. Maintaining a fork is a much larger scope expansion than adding `typescript` as a devDep, and gives up the upstream guarantee that the rule set tracks Next.js best practices.
**Why option c (keep things broken; CI's `|| true` wrapper tolerates lint failures) was dismissed.** CI's `|| true` wrapper tolerates a non-zero exit code from `eslint`, but it does NOT tolerate a `MODULE_NOT_FOUND` thrown during config load — the crash happens before ESLint emits any structured output, and the wrapper still passes the exit code to the shell, but **lint stops being a useful signal entirely**. Every CI lint run would be a no-op pass. That regresses the lint surface to "always green, regardless of code quality" and silently invalidates the `fix-lint-baseline` convoy's premise (which assumes lint at least executes). Unacceptable.
**Out-of-scope (still deferred):**
- All items deferred under Decisions A and B remain deferred.
- TypeScript adoption as a project language (no `tsconfig.json`, no `.ts`/`.tsx` source files, no `// @ts-check` directives, no `.d.ts` declaration files). `typescript` is installed purely so `eslint-config-next`'s lint chain can load. If the team later decides to migrate to TypeScript, that's an explicit, separate convoy — not a "while we're here."
- Adding `@typescript-eslint/parser` or `@typescript-eslint/eslint-plugin` directly. They're already pulled in transitively by `eslint-config-next@16`; no direct dep needed.
- CI workflow changes (still per Decision B's deferral note — pickup point is `adopt-vitest`).
**Canonical authority.** This Decisions entry IS the canonical authority. Decision recorded in chat on 2026-05-23 between user and `role-architect`, immediately after the implementer's escalation on first lint run. Boot-the-brief #8's misreading of `peerDependenciesMeta.optional` is corrected in place in `brief-1-bump-next-and-migrate-config.md` (finding #8 marked "🔴 SUPERSEDED by Decision C"; new findings #16#18 added under "Decision C narrow recheck").
**Updated consequences for downstream roles** (delta from Decision B):
- `role-implementer`: command becomes `npm install next@^16.2.6 eslint@^10.4.0 eslint-config-next@^16.2.6 typescript@^5.9.3` (or equivalently, run the previous three-package install, then run `npm install --save-dev typescript@^5.9.3` as a follow-up — order doesn't matter; the lockfile is regenerated either way). Re-run `npm run lint` after the install; expectation is now that lint completes with the pre-existing baseline of errors (no `Cannot find module 'typescript'` crash). Failure-mode classification: see Brief 1's "Local verification" section — `Cannot find module 'typescript'` post-install means the install didn't take and is not R15; a `TypeError: context.getCwd is not a function` (or similar v9-deprecated-API error) is R15 and triggers a v9 fallback (keeping the `typescript` install).
- `role-reviewer`: lockfile diff will now additionally show the `typescript` package being added. The diff is small (TypeScript has no transitive deps). Verify the brief's no-scope-expansion guardrails were respected — specifically that no `tsconfig.json` was created and no `.js` files were renamed to `.ts`/`.tsx`.
- `role-design-system-auditor`, `role-a11y-auditor`, `role-doc-writer`: unchanged from Decisions A and B.
### D — 2026-05-23: Revert ESLint v10 → v9.39.4 after R15 fired empirically; queue `bump-eslint-10` as upstream-blocked follow-up
**Context.** After Decisions A, B, and C were applied, `role-implementer` ran the migration locally a second time (pass 2). The Decision-C `typescript` install resolved the original `Cannot find module 'typescript'` crash from pass 1 — but the lint run then surfaced a different `TypeError`:
- **Crash signature:** `TypeError: scopeManager.addGlobals is not a function`
- **Call site:** ESLint v10's `lib/source-code/source-code.js:221` calls `scopeManager.addGlobals(...)` as part of v10's redesigned global-ingestion path.
- **Missing-method site:** `@typescript-eslint/scope-manager@8.59.4` (transitive dep of `typescript-eslint@8.59.4`, which `eslint-config-next@16.2.6` bundles as a regular `dependency`) does not implement `addGlobals` on its `ScopeManager` class.
- **Why:** `@typescript-eslint/scope-manager@8.x` was published Oct/Nov 2025; ESLint v10 GA was 2026-02-06. The `addGlobals` method is a v10-introduced extension of the `ScopeManager` interface; v9 used a different ingestion path. `typescript-eslint` has not yet shipped a v10-tested release that adds the method.
This is **the empirical firing of Risk R15**, in a different shape than originally feared. The principal failure mode anticipated at Decision B was a v9-deprecated-API removal (`context.getCwd()`, `SourceCode#getJSDocComment()`, etc.); the actual failure was a v10-introduced-method gap on the typescript-eslint side. Either way, the diagnosis is the same: `eslint-config-next@16.2.6`'s pre-v10-GA bundled plugin set is not runtime-compatible with v10. Reverting `eslint` to v9 is the only working option until upstream catches up.
**The decision.** User chose **option (a) — execute the brief's documented v9 fallback path AND formally queue a follow-up `bump-eslint-10` convoy** at the gate. This partially reverses Decision B's "avoid back-to-back convoys" rationale, but **Decision B was correct given Boot-the-brief evidence at the time** — the v10 incompat was statically unprovable until a real lint run hit `addGlobals`. Empirical evidence from pass 2 reverses the call.
Specific changes:
- `package.json`: re-pin `devDependencies.eslint` from `"^10.4.0"` back to `"^9.39.4"` (npm's `maintenance` dist-tag).
- **`devDependencies.typescript: "^5.9.3"` (Decision C) is RETAINED.** Boot-the-brief recheck #17 confirmed at Decision C, and the implementer's pass-2 evidence reconfirmed, that the same `typescript-eslint@8.59.4` chain hard-`require`s `typescript` under v9 too. The typescript install is correct independent of the eslint pin.
- **`eslint-config-next@^16.2.6` is unchanged** — its peer-dep `eslint: ">=9.0.0"` accepts v9.39.4 trivially; no `<10` upper bound shift since Decision B's verification.
- **`next@^16.2.6` is unchanged.**
- **`eslint.config.mjs` shape is unchanged.** `defineConfig` and `globalIgnores` from `eslint/config` were introduced in 9.21.0 and retained in v10.4.0; the same import line resolves correctly on both v9.39.4 and v10.4.0. **This is the load-bearing reason Decision D is a one-line `package.json` re-pin and not a multi-file rollback.** When `bump-eslint-10` lands, this file should not need to change.
- Install command for the implementer: `npm install --save-dev eslint@^9.39.4 eslint-config-next@^16.2.6 typescript@^5.9.3` (single atomic command preferred; running them separately is equivalent — the lockfile regenerates either way).
**Why option b (npm overrides to force a v10-compat `@typescript-eslint/scope-manager`) was dismissed.** The brief explicitly forbids transitive patching ("do NOT patch the plugin or pin transitive deps mid-flight"). There's no guarantee that any released version of `@typescript-eslint/scope-manager` exists with the v10 fix at a version `eslint-config-next@16.2.6` will resolve to under its bundled `typescript-eslint@^8.46.0` constraint. Even if such a version existed, npm overrides bypass the upstream maintainer's compatibility testing entirely — we'd be hand-rolling a custom dep tree that no other project uses, which moves the maintenance burden to us.
**Why option c (wait for upstream) was dismissed.** Doesn't unblock the Vercel deploys gate that's the convoy's success metric ("npm install next@16.2.6 ships, Vercel deploys complete, no runtime regressions in dev or build"). The convoy's premise is that Vercel was rejecting `next@^15.4.2` as a "Vulnerable version of Next.js"; we have to ship `next@^16.2.6` now. Pinning `eslint` to v9 lets us do that today; v10 can land later when prerequisites are met.
**Why option d (ship with the lint crash) was dismissed.** Same reasoning as Decision C's option-c dismissal: CI's `|| true` wrapper tolerates a non-zero exit code, but a `TypeError` crash before any rule executes means lint emits no useful signal at all — every CI lint run becomes a no-op pass, and `fix-lint-baseline`'s premise (lint at least executes) collapses. Unacceptable.
**Reversal of Decision B's "avoid back-to-back `bump-eslint-10` convoy" rationale.** Acknowledged. Decision B's argument was: pivot to v10 now to avoid a follow-up convoy. That argument was correct given the static evidence available at the time of Decision B (peer-dep ranges accepted v10; Boot-the-brief found no obvious incompatibilities). Decision D's empirical evidence — a `TypeError` from a real lint run — supersedes the static evidence. The follow-up `bump-eslint-10` convoy is now formally queued (see "Follow-up convoys queued" section below); it will land as a single-brief mechanical bump once upstream prerequisites are met.
**Out-of-scope (still deferred):**
- All items deferred under Decisions A, B, and C remain deferred.
- The `bump-eslint-10` follow-up is queued, not authored. The conductor will materialize a convoy file when a human authors one (likely after `typescript-eslint` ships a v10-tested release and `eslint-config-next` bundles it).
- npm `overrides` field manipulation, transitive-dep pinning, plugin forking — all forbidden as scope expansion under both this convoy and the future `bump-eslint-10`.
**Canonical authority.** This Decisions entry IS the canonical authority. Decision recorded in chat on 2026-05-23 between user and `role-architect`, immediately after the pass-2 implementer escalation surfaced R15's `addGlobals` firing.
**Updated consequences for downstream roles** (delta from Decision C):
- `role-implementer`: re-run `npm install --save-dev eslint@^9.39.4` (the other three packages — `next@^16.2.6`, `eslint-config-next@^16.2.6`, `typescript@^5.9.3` — are already at correct versions from Decision C's run); re-run `npm run lint`. **Expectation:** exit code 1 or 2 (baseline lint errors present) is fine; exit code 0 is improbable until `fix-lint-baseline`. **NOT expected:** a `TypeError` crash. R15's `scopeManager.addGlobals` signature should not recur on v9 because v9 doesn't call `addGlobals`. If a different `TypeError` fires on v9, escalate via the brief's failure-mode classifier.
- `role-reviewer`: lockfile diff under Decision D stays on the v9 dep-tree; `@eslint/eslintrc` (a v9 transitive) remains present. Verify that `package.json` has `eslint: "^9.39.4"` (not `^10.x`), `typescript: "^5.9.3"`, `eslint-config-next: "^16.2.6"`, and `next: "^16.2.6"`.
- `role-design-system-auditor`, `role-a11y-auditor`, `role-doc-writer`: unchanged from Decisions A, B, C. Doc-writer's `AGENTS.md` pass should mention the `bump-eslint-10` queued convoy if the doc-writer pass surfaces lint-toolchain documentation.
- **Future `bump-eslint-10` implementer:** inherits R15's diagnostic verbatim. The convoy will become a single-brief mechanical bump once `typescript-eslint` ships v10 support and `eslint-config-next` bundles it; until then, the convoy is upstream-blocked and not authored.
## Follow-up convoys queued
The following convoys are formally queued by `role-architect` as upstream-blocked follow-ups to this convoy. They are NOT authored as convoy files yet — they exist in this list only. The conductor will materialize a convoy file when a human authors one and the upstream prerequisites are met.
### `bump-eslint-10` — upstream-blocked
- **Origin.** Queued under Decision D (2026-05-23) after R15 fired empirically. Decision B's pivot to v10 was reverted; v10 is still the supported `latest` and we want to land it eventually.
- **Prerequisites (both must be met before the convoy can run):**
1. `typescript-eslint` ships a v10-tested release. Likely shape: `@typescript-eslint/scope-manager` adds the `addGlobals` method (and any other v10-introduced `ScopeManager` interface members) on the v8.x line as a backport, OR the typescript-eslint v9 line ships and adds them. Either way, the release notes will explicitly mention ESLint v10 compatibility.
2. `eslint-config-next` bundles a v10-tested `typescript-eslint`. Likely shape: a `16.3+` release that bumps the `typescript-eslint` direct dependency. Confirmed by reading the `eslint-config-next` `package.json` `dependencies.typescript-eslint` range and cross-referencing typescript-eslint's release notes.
- **Convoy shape (when materialized):** single-brief mechanical bump matching this convoy's Brief 1 shape. Files: `package.json` (re-pin `eslint` from `^9.39.4` to `^10.x.y`; re-pin `eslint-config-next` if a new minor bundles the v10-tested typescript-eslint), `package-lock.json` (regenerate). No code-shape changes expected — `eslint.config.mjs` is verified compatible on both v9 and v10. No CI workflow changes unless the Node-version pin question (originally raised under R9) bites at v10's stricter floor.
- **Risks inherited from this convoy:** R12's "lint baseline shifts more under v10" warning resurfaces; the three new `eslint:recommended` rules, JSX reference tracking, `no-shadow-restricted-names.reportGlobalThis: true` default, and `eslint-env`-comments-as-errors transition all happen at this convoy. CI's `|| true` wrapper still tolerates. R9's CI moving-target concern (`node-version: '20'` resolution) reactivates under v10's `^20.19.0` floor; the `bump-eslint-10` brief should pin CI to `node-version: '20.19'` or `'lts/iron'` if the target convoy permits CI workflow changes.
- **Cost of being wrong about prerequisites:** small. If `typescript-eslint` ships a v10-tested release and `eslint-config-next` bundles it but `bump-eslint-10` still surfaces a different incompat at runtime, the convoy itself documents another decision letter and re-pins back to v9 again. The cost is one extra Boot-the-brief recheck and one extra Decisions entry.
### `bump-typescript-6` — upstream-blocked
- **Origin.** Queued under Decision D (2026-05-23). Surfaced during Decision C's Boot-the-brief recheck (#16): `npm view typescript@latest` returned `6.0.3` (latest major), but `^5.9.3` was pinned because `typescript-eslint@8.59.4`'s peer range `>=4.8.4 <6.1.0` accepts but doesn't certify v6.
- **Prerequisites:**
1. `typescript-eslint` advertises explicit v6 support in a release. Currently the peer range accepts `<6.1.0` (so `typescript@6.0.x` is technically in range) but typescript-eslint has not announced v6 testing. Likely shape: a release-notes entry titled "TypeScript 6 support" or a peer-range bump to `<7.0.0` once they're confident.
2. (Optional) `eslint-config-next` bundles a `typescript-eslint` version that advertises v6 support. Not strictly required — `typescript@^5.x` in `bump-eslint-10` and `typescript@^6.x` here can be re-pins on different days.
- **Convoy shape (when materialized):** single-brief mechanical bump. Files: `package.json` (re-pin `typescript` from `^5.9.3` to `^6.x.y`), `package-lock.json` (regenerate). No code-shape changes — TypeScript is only used by ESLint's lint chain, not by source files (no `tsconfig.json`, no `.ts` files; the `bump-typescript-6` convoy must preserve those guardrails).
- **Risks inherited from this convoy:** none specific. The verified runtime require evidence in Brief 1 #17 stays valid (typescript-estree's `require('typescript')` sites are unconditional regardless of TS major).
- **Cost of being wrong about prerequisites:** small. Same fallback shape as `bump-eslint-10`.
### Notes on materialization
These two convoys can land independently, in either order. Neither blocks the other. The conductor should expect a human to author the convoy file (frontmatter + IA + UX) when they decide to land the upgrade; `role-architect` does not pre-author convoy files for upstream-blocked follow-ups (no Boot-the-brief evidence exists yet to verify against).

View file

@ -0,0 +1,216 @@
---
convoy: bump-next-js
brief_number: 1
depends_on: []
files:
- package.json
- package-lock.json
- next.config.js
- eslint.config.mjs
deletes:
- .eslintrc.json
---
# Brief 1: Bump Next.js to 16.2.6 + migrate `next.config.js`, ESLint flat config, and lint script
## Goal (1 sentence)
Replace `next@15.4.3` with `next@16.2.6` so Vercel's platform-level security gate stops blocking every deploy, while paying off the three blockers the upgrade actually introduces in `tcg-vault`: the deprecated `images.domains` config, the **removed** `next lint` command (which forces a `package.json` script change AND an ESLint v8 → **v10** + flat-config migration so `eslint-config-next` can be bumped to `^16` to match `next`), and Turbopack-by-default (no code change required, just informed acceptance).
## Files in scope (do not edit anything else)
- `package.json` — modified
- `package-lock.json` — modified (regenerated)
- `next.config.js` — modified
- `eslint.config.mjs` — **new**
- `.eslintrc.json` — **deleted**
## Conventions to follow
- `AGENTS.md` § "Tech stack quick reference": Next.js 15 (Pages router) → bump the framework version string only as part of the doc-writer pass, not in this brief. **Do not edit `AGENTS.md` here.** That's a separate role-doc-writer PR.
- `.cursor/rules/no-go-zones.mdc`: do not touch anything outside `files:` above. In particular: no edits to `pages/`, `components/`, `lib/`, `scripts/`, `styles/`, or any workflow file.
- `package.json` formatting: 2-space indent, double-quoted keys/values, trailing newline. Match existing style.
- `next.config.js` formatting: ESM (`export default nextConfig`), 2-space indent, JSDoc `@type` comment preserved.
- `eslint.config.mjs` formatting: ESM, 2-space indent, no semicolons-only-when-needed convention (match the verbatim shape from the official Next.js docs cited below).
- The lock file must be regenerated by `npm install`, not hand-edited.
## Acceptance criteria
### `package.json` changes
- [ ] `dependencies.next` is `"^16.2.6"` (from `"^15.4.2"`).
- [ ] `dependencies.react` and `dependencies.react-dom` stay at `"^18.3.1"`. Next 16 peer-deps accept `react ^18.2.0 || ^19.0.0`; current `18.3.1` is in range. React 19 is its own convoy.
- [ ] `devDependencies.eslint` is `"^9.39.4"` (from `"^8"`). **Per gate-1 Decision D (2026-05-23), reverted from `^10.4.0` back to `^9.39.4` after R15 fired empirically on the implementer's pass-2 run** (`TypeError: scopeManager.addGlobals is not a function` from ESLint v10's `source-code.js:221` calling a method that `@typescript-eslint/scope-manager@8.59.4` — bundled by `eslint-config-next@16.2.6`, published before v10 GA — does not implement). v10 will be picked up under the queued follow-up convoy `bump-eslint-10` once `typescript-eslint` ships a v10-tested release and `eslint-config-next` bundles it. v9.39.4 is on npm's `maintenance` dist-tag (current `latest` is 10.4.0). See Boot-the-brief findings #5#10 below for the v10-pivot verification (now historical) and the Decision D recheck below for v9 sanity.
- [ ] `devDependencies.eslint-config-next` is `"^16.2.6"` (from `"15.4.2"`). `eslint-config-next@16.2.6`'s peer dep `eslint: ">=9.0.0"` accepts v9.39.4 (re-verified via `npm view eslint-config-next@16.2.6 peerDependencies`).
- [ ] `devDependencies.typescript` is `"^5.9.3"` (newly added). **Per gate-1 Decision C (2026-05-23), `typescript` is a hard runtime requirement** of `eslint-config-next@16.2.6` despite its package.json's `peerDependenciesMeta.typescript.optional: true` annotation. The annotation only suppresses npm's install-time warning; it does NOT mean the runtime path is optional. `eslint-config-next` bundles `typescript-eslint@^8.46.0`, whose `@typescript-eslint/typescript-estree@8.59.4` dependency does an unconditional `require('typescript')` at module load (verified at `dist/convert.js:40` of the published tarball). Without `typescript` installed, `npm run lint` crashes with `Cannot find module 'typescript'` before any rule runs. See Boot-the-brief findings #8 (superseded) and #16#17 below.
- [ ] `scripts.lint` is `"eslint ."` (from `"next lint"`). The `next lint` command was removed in Next.js 16; it would throw at runtime if left in place.
- [ ] No new direct dependencies are added beyond the four already listed (`next` bumped, `eslint` bumped, `eslint-config-next` bumped, `typescript` newly added). **In particular, do NOT add `@eslint/eslintrc`**`eslint-config-next@16.2.6` ships native flat-config exports, so no `FlatCompat` shim is needed (see Boot-the-brief finding #1). Do NOT add `@typescript-eslint/parser`, `@typescript-eslint/eslint-plugin`, `tsx`, `ts-node`, or any other TS toolchain — `typescript` alone resolves the lint crash.
- [ ] No new `engines` block is added to `package.json`. Node 20.19+ is required by `eslint@10` (`next@16` requires 20.9+), but the existing `setup-node@v4` step in `.github/workflows/ci.yml` (`node-version: '20'`, which resolves to latest 20.x ≥ 20.19) and Vercel's default Node 22 runtime both satisfy this. Adding an `engines` block is out of scope (see Risk R9 for the residual CI-pin concern).
### `package-lock.json` changes
- [ ] Regenerated via `npm install` (no hand edits).
- [ ] `npm ls next` reports `next@16.2.6`.
- [ ] `npm ls eslint` reports `eslint@9.39.x` (or whatever 9.x patch `^9.39.4` resolves to). **Not `10.x`** — see Decision D below.
- [ ] `npm ls eslint-config-next` reports `eslint-config-next@16.2.x`.
- [ ] `npm ls typescript` reports `typescript@5.9.x` (latest 5.x; per Decision C — see Boot-the-brief #16 for the v6 deferral rationale).
- [ ] `npm install` exits cleanly with no `ERESOLVE` peer-dep failures and no `npm warn deprecated` for any of the four packages above. (Note: under Decision D the lockfile diff stays on the v9 dep-tree — `@eslint/eslintrc` is still present as a v9 transitive dep; the v10 dep-tree changes that would have removed it are deferred to the queued `bump-eslint-10` follow-up convoy. The new `typescript` subtree should still be small — TypeScript itself has no `dependencies`.)
### `next.config.js` changes
- [ ] Migrates `images.domains``images.remotePatterns`. Verbatim shape (from the [official Next 16 upgrade guide](https://nextjs.org/docs/app/guides/upgrading/version-16) § "`images.domains` Configuration (deprecated)"):
```js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'api.scryfall.com' },
{ protocol: 'https', hostname: 'images.pokemontcg.io' },
{ protocol: 'https', hostname: 'lorcana-api.com' },
],
},
};
export default nextConfig;
```
- [ ] No other keys are added to `next.config.js`. In particular: do **not** add `cacheComponents`, `reactCompiler`, `turbopack`, or `experimental.*` flags. Those are opt-in features for follow-up convoys. **Do not** add `--webpack` opt-out — see Risk R1.
### `.eslintrc.json` deletion + `eslint.config.mjs` creation
- [ ] `.eslintrc.json` is deleted. (It currently contains exactly `{"extends": "next/core-web-vitals"}`. ESLint v9 still tolerates legacy `.eslintrc.*` if `ESLINT_USE_FLAT_CONFIG=false` is set, but the codebase is moving to flat config; leaving both files would be a footgun.)
- [ ] `eslint.config.mjs` is created with the verbatim shape below. **This shape comes directly from the [official Next.js docs for `eslint-config-next` v16+](https://nextjs.org/docs/app/api-reference/config/eslint)** — do not improvise, do not add new rules, do not "while we're here" any plugin disables. The only deviation from the docs example is one extra path (`scripts/migrations/**`) added to `globalIgnores` per the user's gate-1 instruction.
```js
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
const eslintConfig = defineConfig([
...nextVitals,
globalIgnores([
'.next/**',
'node_modules/**',
'out/**',
'build/**',
'next-env.d.ts',
'scripts/migrations/**',
]),
]);
export default eslintConfig;
```
Notes for the implementer (do not include these as comments in the file — they're for the PR description):
- **The verbatim shape is unchanged across the v8 → v9 (Decision A) → v10 (Decision B) → v9 (Decision D) ping-pong.** `defineConfig` and `globalIgnores` from `eslint/config` exist in both v9.39.4 (added 9.21.0) and v10.4.0 (retained); the import line works identically on both majors. Decision D rolls back only the `package.json` pin — no flat-config edit required. When the queued `bump-eslint-10` follow-up convoy lands, this file should not need to change.
- `defineConfig` and `globalIgnores` are built-in helpers exported from `eslint/config` (added in ESLint 9.21.0, retained in v10). `eslint@9.39.4` has them; `eslint@10.4.0` would also have them, but Decision D pins v9.39.4.
- `eslint-config-next/core-web-vitals` is a CommonJS array re-exported as the default — spreadable with `...nextVitals` (verified by extracting the `eslint-config-next@16.2.6` tarball; see Boot-the-brief finding #1).
- `eslint-config-next` already includes default ignores for `.next/**`, `out/**`, `build/**`, and `next-env.d.ts`. We restate them here to match the docs example exactly and to be explicit about what's ignored.
- `node_modules/**` is added explicitly even though ESLint default-ignores it; user's gate-1 instruction lists it as a minimum-cover ignore.
- `scripts/migrations/**` is preemptive — the folder doesn't exist yet (`.cursor/rules/no-go-zones.mdc` calls it "TBD"), but we ignore it now so the eventual migration script convoy doesn't have to remember to.
- `next-env.d.ts` doesn't exist in `tcg-vault` (JS-only project, no TS). Including it is harmless and matches the docs example verbatim.
- **No `parserOptions`, no `rules:` overrides, no `settings:` block.** This brief preserves the exact lint behavior of the previous `.eslintrc.json` extends. Any rule tuning belongs in `fix-lint-baseline`.
### Local verification (run before pushing)
- [ ] `npm install` resolves cleanly with no `ERESOLVE` peer-dep failures.
- [ ] `npm ls next eslint eslint-config-next` prints the three expected versions (16.2.6, 10.4.x, 16.2.x).
- [ ] `npm run dev` boots, prints something like `▲ Next.js 16.2.6 (Turbopack)`, and serves `/` without runtime errors. **No deprecation warning about `images.domains`** is logged at startup.
- [ ] `npm run build` exits with code 0. (Turbopack is the default bundler in 16; tcg-vault has no `webpack:` block in `next.config.js`, so no `--webpack` opt-out is needed.)
- [ ] Manually smoke the routes `TESTING_GUIDE.md` calls out: `/`, `/login`, `/signup`, `/cards`, `/collections`. They render the same as before — no React hydration errors, no 500s.
- [ ] **`npm run lint` runs ESLint v9.39.4 to completion without a `TypeError` crash, emitting the pre-existing baseline of ~100 errors.** This is the integration test for the v8 → v9 + flat-config + `typescript`-devDep migration. Exit code 1 (lint errors present) is **expected**; exit code 0 is improbable until `fix-lint-baseline` runs; CI's `|| true` wrapper tolerates either. **Do not fix lint errors in this brief.** Counting the exact baseline is `fix-lint-baseline`'s job.
- [ ] **If `npm run lint` crashes after `typescript@^5.9.3` is installed AND `eslint@^9.39.4` is pinned**, classify the failure mode:
- `Cannot find module 'typescript'` or similar module-resolution error → **`typescript` install didn't take.** Re-run `npm install`; verify `node_modules/typescript/package.json` exists; verify `package.json` `devDependencies.typescript` is `"^5.9.3"`. Do not investigate further — this should be deterministic now that Decision C is in place.
- `TypeError: scopeManager.addGlobals is not a function` (or any other `scopeManager.*` / `SourceCode.*` `is not a function` error) → **`eslint` pin didn't take, you're still on v10.** Re-run `npm install`; verify `node_modules/eslint/package.json` reports `9.39.x`; verify `package.json` `devDependencies.eslint` is `"^9.39.4"`. This was Risk R15's empirical signature on the pass-2 implementer run; it should NOT recur once v9 is pinned. If it does recur on v9 with a different `is not a function` shape (hypothetically — no evidence this happens), escalate to `role-architect` rather than patching transitive deps.
- `TypeError: context.getCwd is not a function` / `SourceCode.prototype.getJSDocComment is not a function` (the v9-deprecated-API signatures originally feared at Decision B) — **not expected on v9** because these APIs are still present in v9 (only removed in v10). If this fires anyway, escalate; do not patch.
- Anything else (parse error in a source file, unhandled exception in a rule) → **Lint baseline drift.** This is `fix-lint-baseline`'s problem, not this convoy's. CI's `|| true` wrapper tolerates it.
### Vercel preview verification (after pushing the PR)
- [ ] Vercel produces a Preview deployment whose status moves to **Success** (not "Error" / "Vulnerable version of Next.js detected").
- [ ] The Preview URL renders `/` end-to-end (not just the build page).
- [ ] Capture the Preview URL in the PR description so reviewers (`role-reviewer`, `role-design-system-auditor`, `role-a11y-auditor`) can hit it during the audit fan-out.
### No-scope-expansion guardrails
- [ ] No file outside the `files:` / `deletes:` lists is modified.
- [ ] No new dependencies beyond the four already specified (two devDep version bumps — `eslint`, `eslint-config-next`; one new devDep — `typescript`; one regular dep bump — `next`). In particular: no `@eslint/eslintrc`, no `@eslint/js`, no `@typescript-eslint/parser`, no `@typescript-eslint/eslint-plugin`, no `tsx`, no `ts-node`, no `babel-plugin-react-compiler`, no `@playwright/test`, no `vitest`. Those belong to other convoys.
- [ ] No `eslint.config.mjs` rule tuning beyond the documented `globalIgnores` list. If `eslint-config-next@16` + `eslint@10` surfaces additional warnings/errors, defer to `fix-lint-baseline`.
- [ ] **No `tsconfig.json` is created.** Decision C adds `typescript` as a devDep purely so `eslint-config-next@16`'s bundled `typescript-eslint` chain can `require('typescript')` at module load — `tcg-vault` remains a JavaScript project and no source files are migrated to `.ts` / `.tsx`. If a future convoy adopts TypeScript, that's a separate, explicit decision.
- [ ] No `.js` / `.jsx` files are renamed to `.ts` / `.tsx`. No `// @ts-check` directives are added. No `.d.ts` declaration files are created.
- [ ] No `AGENTS.md` edits. Doc-writer pass happens in a separate PR via `role-doc-writer`.
- [ ] No `<Image>` or `<img>` migrations. Audit confirmed `tcg-vault` does not import `next/image` anywhere; pages use plain `<img>`. The `images.remotePatterns` config is being kept (rather than deleted) because it's pre-staged for the eventual `next/image` adoption.
- [ ] No `tests added` checkbox: tcg-vault has no test runner installed yet. Adoption is tracked under `adopt-vitest`. Manual smoke per `TESTING_GUIDE.md` is the verification mechanism.
- [ ] No `--webpack` flag added to `npm run dev` or `npm run build`. Turbopack-by-default is accepted per gate-1 decision; fallback procedure is documented in Risk R1 (in the convoy file) and in this brief's Rationale.
## Rationale (≤3 sentences)
The Next 15 → 16 jump in tcg-vault is unusually narrow at the framework layer (no App Router, no `middleware.js`, no `next/cache`, no `next/image`, no `getServerSideProps`/`getStaticProps`, no `unstable_*`), but the lint toolchain has to move in lockstep: `next lint` was removed, `eslint-config-next@16` requires `eslint >= 9.0.0` (flat config) AND a present `typescript` install (despite its `peerDependenciesMeta.typescript.optional: true` annotation — the bundled `typescript-eslint` chain hard-`require`s `typescript` at module load), and the existing `.eslintrc.json` legacy stub can't extend it — so this single PR bumps `next`, bumps `eslint` to **v9.39.4** (per gate-1 Decision D, after Decision B's earlier v10 pivot was empirically reversed by Risk R15 firing on the implementer's pass-2 run with `TypeError: scopeManager.addGlobals is not a function`; v10 will be picked up under the queued upstream-blocked `bump-eslint-10` follow-up), bumps `eslint-config-next` to `^16` matching `next`, **adds `typescript@^5.9.3` as a devDep** (per gate-1 Decision C), replaces `.eslintrc.json` with `eslint.config.mjs` (same shape works on both v9 and v10, so no further edit needed when `bump-eslint-10` lands), and changes the `package.json` lint script. Both the Next bump and the ESLint migration touch `package.json`, so they cannot run in parallel anyway — keeping them in one brief gives reviewers one PR, one Vercel preview, and one revert boundary if anything regresses. React stays at 18.3.1 (16's peer-deps accept it; React 19 is `bump-react`), Turbopack-by-default is accepted as-is (no `webpack:` config exists; fallback per Risk R1), no `tsconfig.json` is created (tcg-vault remains a JS-only project), and the ~100-error lint baseline stays untouched per `fix-lint-baseline`'s charter. Decision B's pivot to v10 was the right call given Boot-the-brief evidence at the time; Decision D rolls it back specifically because empirical lint runs surfaced the R15 incompatibility with `eslint-config-next@16.2.6`'s pre-v10-GA bundled plugins.
## Boot-the-brief findings (Architect verified 2026-05-22; re-verified 2026-05-23 after gate-1 scope expansion to ESLint v9; re-verified again 2026-05-23 after gate-1 Decision B pivoted to ESLint v10; re-verified narrowly 2026-05-23 after gate-1 Decision C added `typescript` devDep in response to implementer escalation; re-verified narrowly again 2026-05-23 after gate-1 Decision D reverted the v10 pivot back to v9.39.4 in response to pass-2 implementer escalation showing R15 fired empirically with `TypeError: scopeManager.addGlobals is not a function`)
**Note on findings #5#10:** These document the v10-pivot Boot-the-brief from Decision B. They are kept as historical record (the conclusions about v10's engines, peer deps, and `eslint/config` exports are still factually correct) but are **superseded for the active pin** by Decision D. The active `eslint` pin is `^9.39.4` per Decision D's recheck below; v10 is queued under the upstream-blocked `bump-eslint-10` follow-up convoy.
These were verified before publishing the brief:
1. **`eslint-config-next@16.2.6` ships native flat-config exports — `FlatCompat` is NOT needed.** Verified two ways: (a) `npm view eslint-config-next@16.2.6 exports` returned `"./core-web-vitals": { "default": "./dist/core-web-vitals.js" }`; (b) extracted the published tarball (`npm pack eslint-config-next@16.2.6`, then `tar -xzf`), opened `package/dist/core-web-vitals.js`, and confirmed it ends with `module.exports = config` where `config` is a flat-config array (line 37: `var config = _to_consumable_array(_index.default).concat([...])`, then `module.exports = config`). Original user instruction at gate 1: "Use `FlatCompat` from `@eslint/eslintrc` if `eslint-config-next@16` doesn't ship a native flat-config export." Result: native is shipped, **FlatCompat dropped**, no `@eslint/eslintrc` dep added. (And ESLint v10 dropped `@eslint/eslintrc` from its own dependency tree entirely, so this is doubly the right call.)
2. **Verbatim shape comes directly from the [official Next.js docs](https://nextjs.org/docs/app/api-reference/config/eslint).** That page's "Setup ESLint" section uses exactly the `defineConfig([...nextVitals, globalIgnores([...])])` pattern this brief replicates. The only deviation: this brief adds `'node_modules/**'` and `'scripts/migrations/**'` to the ignores per gate-1 instruction.
3. **`next/core-web-vitals` is still a valid extends in `eslint-config-next@16` — but only via the full subpath `eslint-config-next/core-web-vitals` in flat config.** The legacy `extends: 'next/core-web-vitals'` shorthand was an `.eslintrc.json` (legacy-config) sugar; flat config requires the explicit subpath import. Confirmed both via the Next.js docs and the package's `exports` field (`"./core-web-vitals": ...`).
4. **`defineConfig` + `globalIgnores` are ESLint built-ins from `eslint/config`.** Introduced in ESLint 9.21.0 (Feb 2025); retained in v10.0.0 (Feb 2026). Confirmed by extracting `eslint@10.4.0`'s tarball: `package/lib/config-api.js` re-exports `{ defineConfig, globalIgnores, includeIgnoreFile }` from `@eslint/config-helpers`. Same shape as v9 — no signature change. v10 also adds `includeIgnoreFile` to that module (not used here).
5. **ESLint v10.4.0 is the current `latest` on npm.** `npm view eslint dist-tags` returns `{"latest": "10.4.0", "maintenance": "9.39.4", "next": "10.0.0-rc.2", ...}`. v10.0.0 was released 2026-02-06 per the [release blog post](https://eslint.org/blog/2026/02/eslint-v10.0.0-released/). v9.39.4 is on the `maintenance` tag. Per gate-1 Decision B, this brief pins `^10.4.0`.
6. **`eslint@10.4.0` peer deps:** `jiti: *` with `peerDependenciesMeta.jiti.optional: true`. Optional peer; only required if you author your config in TypeScript (`eslint.config.ts`). This brief uses `eslint.config.mjs` (plain JavaScript ESM), so `jiti` is **not** installed. Note: v10 explicitly requires `jiti >= 2.2.0` if used (per migration guide § "Jiti < v2.2.0 are no longer supported"); not a concern for us.
7. **`eslint@10.4.0` engines: `node ^20.19.0 || ^22.13.0 || >=24`.** This is a tighter floor than v9's `^18.18.0 || ^20.9.0 || >=21.1.0` (Node 18, 21, and 23 are all dropped; Node 20.x floor raised from 20.9.0 to 20.19.0). **CI satisfies:** `setup-node@v4` with `node-version: '20'` resolves to the latest 20.x at install time; latest 20.x as of 2026-05 is well above 20.19.0 (Node 20.19.0 was released 2025-03; many patches since). **Local satisfies:** `node@22.14.0` is in the `^22.13.0` range. **Vercel satisfies:** default Node 22 runtime (22.x ≥ 22.13). All three environments ✓. See Risk R9 for the residual concern (CI's `node-version: '20'` is a moving target — if it ever resolves to a stale < 20.19.0 patch, ESLint v10 will refuse to start; that's a CI-pin question, out of scope here).
8. **`eslint-config-next@16.2.6` peer deps:** `eslint >= 9.0.0` (required), `typescript >= 3.3.1` (declared optional via `peerDependenciesMeta.typescript.optional: true`). **No `<10` upper bound** — re-verified via `npm view eslint-config-next@16.2.6 peerDependencies`. v10 is accepted. **🔴 SUPERSEDED by Decision C (2026-05-23):** the original claim "tcg-vault is JS-only, no `typescript` install needed" was **wrong**. `peerDependenciesMeta.typescript.optional: true` only suppresses npm's install-time peer-dep warning; it does NOT make `typescript` runtime-optional. `eslint-config-next` bundles `typescript-eslint@^8.46.0` as a regular `dependency` (not as a peer), and `@typescript-eslint/typescript-estree@8.59.4` does an unconditional `require('typescript')` at module load (verified via tarball extraction — see finding #17 below). The implementer's first lint run crashed with `Cannot find module 'typescript'` before any rule executed. **Corrected:** `typescript@^5.9.3` is now installed as a devDep. See findings #16 and #17 for the v6-vs-v5 pin choice and the verified runtime-require evidence.
9. **`eslint-config-next@16.2.6` was published before ESLint v10 (Oct 2025 vs Feb 2026), so its bundled plugins were not tested against v10.** Bundled plugin set: `@next/eslint-plugin-next@16.2.6`, `eslint-plugin-react@^7.37.0` (latest published `7.37.5`), `eslint-plugin-react-hooks@^7.0.0` (latest `7.1.1`), `eslint-plugin-import@^2.32.0` (latest `2.32.0`, released 2025-06), `eslint-plugin-jsx-a11y@^6.10.0` (latest `6.10.2`), `typescript-eslint@^8.46.0` (latest `8.59.4`). All published before Feb 2026. **The peer-dep range allows v10, but runtime compatibility is not statically provable.** Captured in Risk R15. Mitigation: the "Local verification" section above classifies failure modes — `Cannot find module 'typescript'` was **not** R15 (it was the Decision C `typescript`-missing failure); a `TypeError: context.getCwd is not a function` (or similar v9-deprecated-API error) **would** be R15 and would trigger a v9 fallback.
10. **v10 user-impacting breaking changes audited against tcg-vault** ([migration guide](https://eslint.org/docs/latest/use/migrate-to-10.0.0)):
- **Node.js floor raised** — covered above (#7).
- **`eslint:recommended` updated (3 new rules enabled).** Will shift baseline. Goes to `fix-lint-baseline`.
- **Old config format removed.** We're already on flat config in this brief — no impact.
- **JSX references now tracked.** Will shift `no-unused-vars` / `no-undef` baseline (likely fewer false positives). Goes to `fix-lint-baseline`.
- **`eslint-env` comments are errors.** `rg "eslint-env"` returned zero matches in tcg-vault source. ✓
- **`stylish` formatter uses native `styleText` instead of `chalk`.** Cosmetic only. Honors `NO_COLOR` / `NODE_DISABLE_COLORS`. No action.
- **`no-shadow-restricted-names` reports `globalThis` by default.** Will potentially add baseline entries. Goes to `fix-lint-baseline`.
- **Plugin-developer changes** (deprecated `context` members, deprecated `SourceCode` methods, `Program` AST range, `RuleTester` strictness, `nodeType` on `LintMessage`). Not applicable to tcg-vault — we don't author plugins. **But these are exactly the APIs `eslint-config-next`'s bundled plugins might have used before v10**; that risk is captured in #9 / R15.
- **POSIX character classes in glob patterns / `radix` rule deprecated options / `func-names` schema / `no-invalid-regexp.allowConstructorFlags` uniqueness.** None apply (we don't override any of these rules; we don't use POSIX glob syntax).
11. **`next@16.2.6` peer deps and engines re-verified.** `react ^18.2.0 || ^19.0.0` ✓ (current `18.3.1`); engines `node >=20.9.0` ✓ (lower than ESLint v10's `^20.19.0` floor — ESLint v10 is now the binding constraint).
12. **No `next/image` usage in tcg-vault.** Re-verified via `rg "from ['\"]next/image['\"]"` — zero hits in `pages/`, `components/`, `lib/`. Convoy file's audit list (line 45) was incorrect.
13. **No `webpack:` config in `next.config.js`.** Turbopack-by-default in `next dev`/`next build` is safe per gate-1 acceptance. Fallback procedure documented in Risk R1 (convoy file).
14. **`.gitignore` already covers `.next/dev/`.** Next 16 splits dev and build outputs; existing `/.next/` rule (line 28) is a directory glob covering both.
15. **`scripts/migrations/` doesn't exist yet.** Per `.cursor/rules/no-go-zones.mdc`, "folder TBD." Adding to `globalIgnores` preemptively is harmless.
### Decision C narrow recheck (added 2026-05-23)
16. **TypeScript pin: `^5.9.3` (not `^6.0.3`).** `npm view typescript dist-tags` returned `{"latest": "6.0.3", "next": "6.0.0-dev.20260416", "rc": "6.0.1-rc", "beta": "6.0.0-beta", "maintenance": "5.9.3", ...}` — TypeScript 6 is the current `latest`, contrary to the gate-1 instruction's parenthetical claim that "5 is the latest TypeScript major." Latest 5.x is `5.9.3`. Two reasons to pin `^5.9.3` and defer v6:
- **Honor the literal gate-1 instruction.** Decision C says "Pin range: `^5`." The parenthetical was a documentation error, not the binding instruction.
- **`typescript-eslint@8.59.4`'s peer range is `>=4.8.4 <6.1.0`.** Strictly, `typescript@6.0.3` IS in range (`<6.1.0` `6.0.3`), so `^6.0.3` would satisfy it. **But:** typescript-eslint historically pins TS minor versions tightly and ships compatibility releases out-of-band; v8.59.4 was published before TS 6 GA and has not advertised explicit v6 support. Pinning `^5.9.3` keeps us inside the well-trodden range until a future convoy bumps `typescript-eslint` to a v6-tested release. `^5.9.3` resolves to the latest 5.x patch (currently `5.9.3` itself) and is well within the peer range.
- **No peer deps on typescript itself.** `npm view typescript@latest peerDependencies` returns empty. `typescript@^5.9.3` adds zero transitive packages — only `typescript`'s own bundle (compiler, language service, declaration files). The lockfile diff is small.
- **Engines.** `typescript@5.9.3` and `typescript@6.0.3` both list `engines.node >= 14.17`, well below ESLint v10's `^20.19.0` floor. No new Node constraint introduced.
17. **Verified the unconditional `require('typescript')` site.** Extracted `@typescript-eslint/typescript-estree@8.59.4`'s published tarball (`npm pack` then `tar -xzf` in `/tmp/ts-estree-pkg`) and grepped `dist/` for `require('typescript')`:
```
dist/convert.js:40: const ts = __importStar(require("typescript"));
dist/useProgramFromProjectService.js:44:const ts = __importStar(require("typescript"));
dist/convert-comments.js:38: const ts = __importStar(require("typescript"));
dist/semantic-or-syntactic-errors.js:4: const typescript_1 = require("typescript");
dist/getModifiers.js:38: const ts = __importStar(require("typescript"));
dist/check-syntax-errors.js:37: const ts = __importStar(require("typescript"));
dist/check-modifiers.js:37: const ts = __importStar(require("typescript"));
dist/version-check.js:38: const ts = __importStar(require("typescript"));
dist/source-files.js:38: const ts = __importStar(require("typescript"));
```
All 9 sites are top-level `require('typescript')` calls — **no `try { require('typescript') } catch {}` gating, no dynamic-import lazy-loader, no `typeof require !== 'undefined' && require.resolve('typescript')` guard.** The package will throw `MODULE_NOT_FOUND` at import time if `typescript` isn't installed. The package's own `peerDependencies.typescript: ">=4.8.4 <6.1.0"` (in `package.json` at the typescript-estree level — **not flagged optional**) is the accurate signal; `eslint-config-next`'s `peerDependenciesMeta.typescript.optional: true` is a **misleading transitive override** at the wrapper level. Conclusion: any consumer of `eslint-config-next@16` MUST install `typescript` to lint. This is true under both ESLint v9 and v10 (same `typescript-eslint` chain), so reverting to v9 would not have fixed the crash.
18. **No `tsconfig.json` in `tcg-vault`.** Verified via `Glob tsconfig*.json` — zero hits. The repo is JS-only (per AGENTS.md §1: "Next.js 15 (Pages router) + React 18, JavaScript (not TypeScript)"). The `typescript` install enables `eslint-config-next`'s lint chain; it does NOT introduce TypeScript as a project language. The "no `tsconfig.json` created" guardrail is enforced explicitly under "No-scope-expansion guardrails."
### Decision D narrow recheck (added 2026-05-23, after pass-2 implementer escalation reversed Decision B's v10 pivot)
19. **`eslint@9.39.4` is still on the `maintenance` dist-tag — no superseding 9.x patch since Decision A.** `npm view eslint dist-tags --json` returned `{"latest": "10.4.0", "maintenance": "9.39.4", "next": "10.0.0-rc.2", "es6jsx": "0.11.0-alpha.0"}`. v9.39.4 was the v9 line's last release before v10 GA on 2026-02-06; the v9 line is in maintenance mode but still receives security backports if needed.
20. **`eslint@9.39.4` peer deps:** `jiti: *` only, with the same `peerDependenciesMeta.jiti.optional: true` semantics as v10 (only required for `.ts` configs; we use `.mjs`). No surprising new peer added since Decision A. **Engines:** `^18.18.0 || ^20.9.0 || >=21.1.0` — looser than v10's `^20.19.0 || ^22.13.0 || >=24` floor. CI's `setup-node@v4` `node-version: '20'` (latest 20.x), local `node@22.14.0`, and Vercel's default Node 22 runtime all satisfy. The R9 residual concern (CI moving target on `node-version: '20'`) **becomes inert under Decision D** because v9's floor is 20.9.0 instead of 20.19.0; any reasonable 20.x patch will satisfy.
21. **`eslint-config-next@16.2.6`'s peer-dep range on `eslint` is unchanged** since Decision B's verification: `>=9.0.0` (no upper bound). v9.39.4 satisfies trivially.
22. **`eslint.config.mjs` shape works on v9.39.4 with zero edits.** `defineConfig` and `globalIgnores` from `eslint/config` were introduced in 9.21.0 (per Boot-the-brief #4) and are present in 9.39.4. The same import line — `import { defineConfig, globalIgnores } from 'eslint/config';` — resolves correctly on both v9.39.4 and v10.4.0. **This is the load-bearing reason Decision D is a one-line `package.json` re-pin and not a multi-file rollback.**
23. **R15 fired empirically on the pass-2 implementer run with the following diagnostic:**
- **Crash signature:** `TypeError: scopeManager.addGlobals is not a function`
- **Call site:** ESLint v10's `lib/source-code/source-code.js:221` calls `scopeManager.addGlobals(...)`.
- **Missing-method site:** `@typescript-eslint/scope-manager@8.59.4` (a transitive dep of `typescript-eslint@8.59.4`, which `eslint-config-next@16.2.6` bundles as a regular `dependency`) does not implement `addGlobals` on its `ScopeManager` class.
- **Why:** `@typescript-eslint/scope-manager@8.x` was published Oct/Nov 2025, before ESLint v10 GA on 2026-02-06. The `addGlobals` method is a v10-introduced extension of the `ScopeManager` interface; v9 used a different ingestion path. `typescript-eslint` has not yet shipped a v10-tested release that adds the v10-required method.
- **Resolution path:** revert `eslint` to v9.39.4 (Decision D). The same `typescript-eslint@8.59.4` works correctly on v9 because v9 doesn't call `addGlobals`.
- **Pre-emptive note for the queued `bump-eslint-10` convoy:** when `typescript-eslint` ships a v10-tested release (likely `8.6.x`+ or `9.x`) AND `eslint-config-next` bundles it (likely `16.3+`), this incompatibility goes away and `bump-eslint-10` becomes a single-brief mechanical bump matching the shape of this convoy.

254
.convoys/ship-readiness.md Normal file
View file

@ -0,0 +1,254 @@
---
name: ship-readiness
classification: epic
success_metric: tcg-vault is safe to expose to anonymous internet traffic with a documented launch checklist green
skip: []
status: open
created: 2026-05-22
---
# Ship-readiness convoy
Umbrella convoy capturing the full agent-pipeline review of tcg-vault as of 2026-05-22. Findings are grouped by L2 role lens (Reviewer / Architect / Design-system / A11y / IA / Doc-writer) and severity. Each item points to the convoy that will execute the fix.
Code graph: 122 files, 628 nodes, 5602 edges, 11 communities. Indexed by `user-code-review-graph` MCP.
## P0 — ship-blockers (security)
These MUST land before any anonymous traffic touches the production URL.
### 1. `getUserFromRequest` returns a hardcoded admin when no Bearer token is present
- **File:** `lib/permission-middleware.js` lines 13-17.
- **Impact:** Every API route that calls `getUserFromRequest` (30+ handlers — see `user-code-review-graph` cross-community edges from `api-handler``lib-admin`) accepts unauthenticated requests as admin user 1.
- **Repro:** `curl https://<host>/api/collections` with no `Authorization` header returns admin's collections.
- **Fix:** Delete lines 13-17. Return `null` when no Bearer token. Update every caller to handle `null` properly (most already do; the broken fallback was masking the right path).
- **Owns:** `role-architect` + `role-implementer` (one PR; small surface area in the helper, callers already check `!user`).
### 2. JWT_SECRET hardcoded fallback in 7 files
- **Files:**
- `pages/api/auth-utils.js` (`'your-secret-key'`)
- `pages/api/auth/login.js`, `pages/api/auth/register.js`, `pages/api/auth/verify.js`
- `pages/api/favorites.js`, `pages/api/users/search.js`
- `lib/permission-middleware.js`
- **Impact:** If `JWT_SECRET` env var is unset (e.g. preview/staging misconfig), tokens are signed with `'your-secret-key-change-in-production'` — an attacker can sign their own admin token in 5 seconds.
- **Fix:** Centralize JWT_SECRET access in one helper that `throw`s at module load if `process.env.JWT_SECRET` is unset. Every other file imports from there.
- **Bonus:** Token expiry is inconsistent (`/api/auth/login.js` uses 24h, `pages/api/auth-utils.js` uses 7d). Pick one.
- **Owns:** `role-architect` + `role-implementer`.
### 3. Default admin credentials in seed + README
- **Files:**
- `scripts/setup-neon-db.js` lines 130-138 — creates `admin@tcgvault.com` / `admin123`
- `README.md` documents the credentials
- `pages/api/setup-database.js` — duplicates the setup AND is an UNAUTHENTICATED public POST endpoint with `Access-Control-Allow-Origin: *`
- **Impact:** Anyone who hits `/api/setup-database` can re-trigger DDL. The `admin123` password is one Google away from public knowledge.
- **Fix:**
1. Delete `pages/api/setup-database.js`. Schema setup is a one-time job; it should not be a route.
2. Change `setup-neon-db.js` to require a `ADMIN_INITIAL_PASSWORD` env var (no default).
3. Strip the admin password from README — replace with "run `npm run setup-db` and follow the prompt".
- **Owns:** `role-implementer`.
### 4. Dev-only test endpoints shipped to production
- **Files:** `pages/api/simple.js`, `pages/api/test-auth.js`, `pages/api/test-db.js`, `pages/api/setup-database.js`.
- **Impact:** Unknown — depends on what they expose. `/api/test-db` likely returns the DB connection string; `/api/test-auth` may leak token-handling details.
- **Fix:** Delete all four. Add a CI grep that fails the build if any file matching `pages/api/(test-|simple|setup-)*.js` exists.
- **Owns:** `role-implementer`.
### 5. CORS `Access-Control-Allow-Origin: *` on auth endpoints
- **Files:** at minimum `pages/api/auth/login.js`, `pages/api/auth/register.js`, `pages/api/setup-database.js` (verify others).
- **Impact:** Any origin can submit credentials. Combined with the no-rate-limit problem below, credential stuffing is wide open.
- **Fix:** Set `Access-Control-Allow-Origin` to the literal frontend origin (`https://tcgvault.com` / preview domain), or remove the header entirely if the API and the frontend are same-origin (they are, on Vercel).
- **Owns:** `role-implementer`.
### 6. No rate limiting anywhere
- **Impact:** Login endpoint accepts unlimited attempts; card-search endpoint can be hammered; image upload endpoints can be exhausted. The `pages/api/cards/import-*.js` endpoints externally hit Scryfall/Pokémon APIs with no caller throttling.
- **Fix:** Adopt `@upstash/ratelimit` (free tier covers a small launch) or Vercel's built-in middleware-based rate limiting. Apply to: `/api/auth/login`, `/api/auth/register`, `/api/users/search`, `/api/cards/search`, all `/api/cards/import-*`, and `/api/user/avatar*` (upload).
- **Owns:** `role-architect` (pattern) → `role-implementer` (per-route).
### 7. Layout default-prop leaks maintainer email
- **File:** `components/Layout.js` line 562: `function Layout({ children, user = { email: 'me@randallstillwell.com', role: 'user' }, ... })`.
- **Impact:** Any page that renders Layout without passing a `user` prop displays your real email and impersonates you as the logged-in user.
- **Fix:** Default `user = null` and render a logged-out state branch. Verify every page passes `user` explicitly (the graph shows ~13 pages call `Layout`; audit each).
- **Owns:** `role-implementer`.
### 8. Next.js 15.4.3 — Vercel platform blocks deploys (vulnerable version)
- **Discovered:** 2026-05-22 during the bootstrap PR CI run. Vercel build completes successfully (~29s) but the deployment exits with status `Error` and `"Vulnerable version of Next.js detected, please update immediately"`.
- **Files:** `package.json` line 22 (`"next": "^15.4.2"` → locked at `15.4.3`), `package-lock.json`.
- **Impact:** **Vercel will not deploy any branch — including `main` — until Next.js is bumped.** Preview URLs are unavailable, which means `preview-smoke.yml` and `visual-diff.yml` can't fire. The last successful deploy on `main` was 2025-08-01; production may already be running an outdated build.
- **CVE context:** Next.js shipped a middleware auth-bypass advisory (CVE-2025-29927) patched in 15.2.3, plus subsequent advisories. The exact CVE Vercel is flagging on 15.4.3 needs confirmation via `npm audit` and the Next.js security advisory page.
- **Fix:** Bump `next` to the latest secure 15.x (`npm install next@^15.5` and run smoke tests) OR the latest 16.x (`next@^16.2.6` — major bump; review breaking changes in [Next.js 16 release notes](https://nextjs.org/blog/next-16)).
- **Owns:** `role-architect` (pick target version + assess breaking changes) → `role-implementer` (bump + verify dev/build/start + smoke).
- **Convoy:** `bump-next-js` — runs before `fix-auth-bypass` lands, OR in parallel as a separate PR. **Without this convoy, every L3 gate that depends on a Vercel preview is non-functional.**
## P1 — pre-launch quality bar
### 8. Two SQL clients in parallel (`@neondatabase/serverless` + `@vercel/postgres`)
- **Impact:** Two different param-handling APIs, two different transaction stories, two different connection-pool stories. Plus `lib/database.js`'s manual interpolation + `sql.unsafe(query)` is a SQL-injection vector if any caller passes user input through.
- **Fix:** Pick `@vercel/postgres` (tagged-template, no injection vector). Migrate every call site of `lib/database.js::db.query`. Delete `lib/database.js`.
- **Reviewer/Architect call:** small enough to fit in one convoy; touches ~3 files based on graph.
### 9. Three parallel client-side auth implementations
- **Files:** `lib/auth-context.js` (`AuthProvider` / `useAuth`), `lib/admin-auth.js` (`AdminProvider` / `useAdmin` / `useIsAdmin`), `lib/use-auth.js` (`useAuth`).
- **Impact:** Pages randomly import from one of three places. State is duplicated. Logout in one provider doesn't necessarily clear the others. Token-verify roundtrips happen 3× on initial page load if all three providers mount.
- **Fix:** Collapse to `lib/use-auth.js` as the canonical hook. Migrate every importer. Delete `auth-context.js` and `admin-auth.js`. Roll up `useIsAdmin` semantics into `useAuth().user?.role === 'admin'`.
- **Owns:** `role-architect` (decision) → `role-implementer` (per-page migration; ~30 importers).
### 10. No tests
- **Impact:** The first agent-driven refactor of `getUserFromRequest` (P0 #1) is high-blast-radius with no safety net.
- **Fix sequence:**
1. Install `vitest`. Add `npm run test:run` script.
2. Install `@playwright/test`. Wire up `tests/smoke/app.smoke.spec.ts` (already drafted; needs `playwright.config.ts`).
3. Re-enable the `test:` job in `.github/workflows/ci.yml` (commented out at install time).
4. Add unit tests for `lib/permission-middleware.js`, `lib/slug-utils.js`, `pages/api/auth-utils.js`.
5. Wire `preview-smoke.yml` to run against the Vercel preview URL.
- **Owns:** `role-architect` (test strategy) → `role-implementer` (initial suite).
### 11. No migration tool — `scripts/add-*.js` graveyard
- **Files:** 27 scripts in `scripts/` of the form `add-foo-column.js`, `fix-bar-constraint.js`, `seed-baz.js`. No idempotency tracking, no `schema_migrations` table, no rollback.
- **Impact:** Onboarding a new env requires re-running every script in the right order. No way to know what's been run on a given Neon branch. Every new column is at risk of being missed in prod.
- **Fix:** Adopt `node-pg-migrate` (lightweight, matches the existing pattern best) OR migrate to `drizzle-kit` if the team wants schema-as-code. Backfill a single "initial" migration matching current prod schema. From there, every new column ships as a migration file.
- **Owns:** `role-architect` (tool selection) → `role-implementer` (backfill + first new migration).
### 11.5. Codebase has ~100 pre-existing ESLint errors
- **Discovered:** 2026-05-22 during the bootstrap PR. The repo had `"lint": "next lint"` in `package.json` but no `.eslintrc.json` — meaning lint was never run. Bootstrap added the config; lint now surfaces ~100 errors.
- **Most serious:** `react-hooks/rules-of-hooks` violations (hooks called conditionally) in several components. These are **real bugs** — React's hook ordering is undefined when hooks are called after early returns. They likely manifest as state-loss / stale-closure bugs in edge cases.
- **Less serious:** `react/no-unescaped-entities` (cosmetic), `react-hooks/exhaustive-deps` (warnings about missing useEffect deps), `@next/next/no-img-element` (cosmetic).
- **Impact:** The L3 CI lint job is currently `continue-on-error: true` (see `.github/workflows/ci.yml`) so it doesn't block PRs. Lint output is visible in logs but PRs merge regardless of lint state until this is cleaned up.
- **Fix:** Triage each error. The rules-of-hooks ones need genuine code restructuring (move hooks before any early returns). The unescaped-entities are mechanical (`'` → `&apos;`). After cleanup, remove `continue-on-error: true`.
- **Convoy:** `fix-lint-baseline` — run after `fix-auth-bypass` and `drop-public-setup`. Multitask-safe: split into briefs by file group.
- **Owns:** `role-architect` (group strategy) → `role-implementer` (per-group fan-out).
### 12. Branding mismatch — "TCG Vault" vs. "Deck Hearth"
- **Files:** README, `package.json`, seed data say "TCG Vault" / `admin@tcgvault.com`. `components/Layout.js` lines 596 + 689 render "Deck Hearth" + "DH" logo. The `.env.local` template, `vercel.json`, and Vercel project name should also be audited.
- **Impact:** Confusing for users. Confusing for marketing. Confusing for analytics. Pick one.
- **Fix:** Brand workshop → final name → global replace → update README, package.json `"name"`, every UI string, Vercel project name, email sender, support pages. Schedule a redirect from the old domain.
- **Owns:** `role-ia-architect` (which name? — needs human decision) → `role-implementer`.
## P2 — refactor priorities
### 13. God components (10 files over 500 lines)
| File | Lines | Notes |
| --- | --- | --- |
| `pages/cards.js` | 1499 | `AuthenticatedCards` (886) + `Card3D` (502) live in one file. Split into `pages/cards/index.js` + `components/Card3D.js`. |
| `pages/collection/[identifier].js` | 1044 | `CollectionView` is one mega-component. Extract: header, card-grid, share-modal-wrapper, edit-form. |
| `pages/collections.js` | 989 | Similar structure to collection/[identifier]. Possibly share extracted pieces. |
| `pages/card/[id].js` | 913 | `CardDetail` — split into header, owned-badge, add-to-collection-flow. |
| `pages/deck-builder.js` | 823 | `DeckBuilder` — extract card-search, deck-list, mana-curve panels. |
| `components/CameraScanner.js` | 817 | Camera + AI-OCR + detection-loop — extract the detection loop into a hook. |
| `pages/admin/card-editor.js` | 778 | Form heavy. Use a `useFormState` pattern + separate the search-results subview. |
| `pages/scanner.js` | 776 | Mirror of CameraScanner concerns plus queue management. |
| `pages/settings.js` | 669 | One screen per settings section is the usual fix. |
| `pages/profile.js` | 625 | Avatar generation logic alone is ~150 lines — extract `useGeneratedAvatar` hook. |
Each is one convoy of its own. Use the architect role's `slice_dependencies:` to fan out implementers safely.
### 14. Schema-design smells (documented in `docs/SCHEMA_MAP.md`)
- `users` has two avatar columns (`profile_image_url` + `avatar_url`). Reconcile.
- `collections` has two visibility flags (`is_public BOOLEAN` + `visibility VARCHAR`). Reconcile.
- `cards.quantity` + `cards.favorited` are unused (they belong on `user_cards` / `user_favorites`). Drop.
- `user_settings` table duplicates several `users` columns. Reconcile.
- All enum-shaped VARCHARs (`role`, `condition`, `theme`, `game`, `visibility`) should be CHECK-constrained or proper Postgres ENUMs.
- `collections.tags` is `TEXT` (comma-separated). Migrate to `JSONB` or a join table.
### 15. Component coupling warning from graph
`user-code-review-graph` flagged:
- High coupling (44 edges) between `components-handle` and `pages-handle` (largely `Layout`, `CardItem`, `ManaCost` — expected for a shared UI surface).
- High coupling (34 edges) between `lib-admin` and `api-handler` — almost all via `getUserFromRequest`. After P0 #1 is fixed, this number stays high because the auth check is genuinely shared — that's fine.
### 16. Lots of inline SVG and emoji
The `getIcon` registry in `Layout.js` and `MobileNavigation.js` redefines the same SVG paths. Extract to `components/icons/` with named exports. Then audit the codebase for inline SVG that should be a named import. Bonus: lazy-load the larger icon families.
## P3 — UX, IA, design-system
### Role-ia-architect findings
- **URL structure** — solid. `/cards`, `/collections`, `/collection/[slug]`, `/deck-builder`, `/community/collections`. Coherent. One quirk: `/card/[id]` (singular) for detail vs. `/cards` (plural) for index — typical Next.js shape but worth a redirect rule so `/cards/[id]` also resolves.
- **Logged-out homepage** — current `pages/index.js` is 316 lines; needs an editorial pass. What's the value prop in one sentence? Right now it's mostly "we have cards".
- **Onboarding** — signup → profile setup → first collection → scan-or-import card. Currently each step is a separate page. Consider a multi-step wizard at `/onboarding` to keep the new user in flow.
- **Discoverability**`/community/decks` and `/community/forums` are in the nav but flagged as placeholders. Either ship the MVP for each before launch (forums likely too big) or hide the nav items until they exist.
### Role-ux-reviewer findings
- **Loading states** — most data fetches set `loading: true` then re-render; very few show skeletons. Card grids should use shimmer placeholders; modals should disable submit while in flight.
- **Error states** — error messages bubble to `console.error` and toast nothing. Add a global toast system (e.g. `sonner`) and wire every catch block.
- **Empty states**`/my-cards` and `/collections` when empty drop to "no cards yet". Replace with first-time CTA: "Scan your first card" or "Browse popular sets".
- **Mobile drawer**`MobileNavigation` is solid (recent commit `442e906`). One thing: the bottom-bar's active state contrast looks low in light mode; verify against AA.
- **Camera scanner UX** — 817 lines of detection loop. Add a one-line "scanning…" status under the viewfinder and a single "captured N cards" badge. The current toolbar is busy.
### Role-design-system-auditor findings
- **Two visual languages mixing** — Tailwind classes AND CSS variables on the same elements. This is documented in `.cursor/rules/ui-and-theming.mdc`; the cleanup is to define which property goes where and enforce.
- **Hardcoded hex colors** — grep for `bg-\[#` and `style={{ backgroundColor: '#`. There are still a handful; convert to theme tokens.
- **Logo + brand** — see P1 #12. Then once the name is settled, the "DH" logo + AnimatedFireLogo need to be unified into one brand mark.
- **Modal patterns**`CollectionSelectionModal`, `ShareModal`, `UploadImageModal` each have their own backdrop + focus-trap implementation. Extract `<Modal>` primitive. Use `headlessui` or `radix-ui`'s Dialog to get focus management for free.
- **Card grid spacing + density**`pages/cards.js` (the 1499-line monster) does responsive grid math inline. Extract a `<CardGrid>` component that handles density (compact / comfortable / spacious) + sort + filter chrome.
### Role-a11y-auditor findings
- **Focus traps in modals** — none of the modals trap focus. Tab through `ShareModal` and you leave to the background. Critical for keyboard users + screen readers.
- **ESC to close modals** — inconsistent. Some have it, some don't.
- **Skip-to-content** — no `<a href="#main" class="sr-only focus:not-sr-only">`. Add to `_app.js`.
- **Image alts** — card images use `alt={card.name}` (good); avatar images sometimes have empty alts. Audit.
- **Color contrast** — verify the muted text colors (`var(--text-secondary)`) hit AA on both themes. The mobile bottom-bar inactive state is a likely fail.
- **Form errors** — login/signup form errors are visually red but not connected to inputs via `aria-describedby`. Screen readers don't know which field failed.
- **Keyboard ops on non-button elements** — most clickable `<div>`s already have `onKeyDown` but a few don't (audit with `rg "onClick" components pages | rg -v "<button"`).
### Role-doc-writer findings
- **README** — needs a public-facing rewrite. Currently mixes user docs + dev setup + admin credentials. Split into `README.md` (project landing) + `docs/DEVELOPMENT.md` (dev setup) + delete the admin credentials section entirely.
- **`docs/SCHEMA_MAP.md`** — installed at bootstrap (this convoy). Keep it fresh on every schema change.
- **CHANGELOG** — none yet. Adopt Keep-a-Changelog format. Backfill `[0.1.0] — initial private alpha` covering everything to date.
- **`TESTING_GUIDE.md`** — currently the only test doc; rename to `docs/MANUAL_QA.md` once `vitest` + `playwright` land.
- **`docs/API_REFERENCE.md`** — would help. Could be auto-generated by walking `pages/api/**/*.js` and extracting JSDoc; or hand-curated to start.
- **Privacy policy / Terms of service** — required before public launch. Use a template (Termly / Iubenda) and customize.
## Proposed launch sequence
Each phase is one Conductor-created convoy. Don't run more than two in parallel until tests exist.
0. **`bump-next-js`** (P0 #8). One PR. **MUST land first** — Vercel is currently blocking all deployments, which makes every other PR's preview-smoke / visual-diff gate non-functional. Trivial bump; risk is breaking changes if going to 16.x.
1. **`fix-auth-bypass`** (P0 #1, #2, #4, #5, #6 partial). One PR. Highest risk; needs human review.
2. **`drop-public-setup`** (P0 #3, #4). One PR. Trivial; do as a hotfix.
3. **`fix-layout-default-user`** (P0 #7). One PR. Trivial.
3.5. **`fix-lint-baseline`** (P1 #11.5). 2-4 PRs via multitask. Closes the lint gate (drops `continue-on-error`).
4. **`add-rate-limiting`** (P0 #6 full). One PR. Adds @upstash/ratelimit + applies to listed routes.
5. **`pick-a-name`** (P1 #12). Human decision first, then one or two PRs.
6. **`adopt-vitest`** (P1 #10 step 1). One PR. Enables testing every future change.
7. **`migration-tool`** (P1 #11). One PR. Backfill + first new migration.
8. **`single-sql-client`** (P1 #8). 2-3 PRs, fanned out via multitask once per-file briefs are written.
9. **`single-auth-provider`** (P1 #9). 3-5 PRs via multitask.
10. **`adopt-playwright-smoke`** (P1 #10 step 2). One PR.
11. **`schema-cleanup`** (P2 #14). Multi-PR convoy via multitask.
12. **`god-component-split`** (P2 #13). One convoy per file; fan out via multitask once architect's `slice_dependencies` are written.
13. **`launch-polish`** (P3). UX/IA/a11y/docs convoy.
Total: ~14 convoys to get from current state to public-launch-ready. Estimate 4-8 weeks at one human-in-the-loop reviewer per convoy. Multitask + Cursor 3.2 worktrees compress steps 8-12 substantially.
## Self-analytics
After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.
## How to start
Per `.cursor/agents/role-conductor.md`, start the next convoy with:
> *"Run role-conductor: start a new convoy `fix-auth-bypass` to address P0 #1, #2, #4, #5, #6 partial in `.convoys/ship-readiness.md`. Success = `getUserFromRequest` returns null for missing tokens; no API route accepts unauthenticated requests; CI green."*
The Conductor will set classification, skip flags, and hand off to subsequent roles.

View file

@ -0,0 +1,105 @@
---
name: role-a11y-auditor
description: >-
Accessibility audit on a UI diff. Checks for missing labels, keyboard
navigation, focus management, color contrast, semantic HTML, and ARIA
correctness. Read-only. Use after the implementer's PR draft on PRs that
touch UI files. Does not require a browser MCP — works from the diff +
static analysis. Safe to run in parallel with role-reviewer +
role-design-system-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: A11y Auditor
## Trigger
After `role-design-system-auditor` on UI-touching PRs. Skip when convoy frontmatter has `skip: a11y`.
## Inputs
- The PR diff (UI files only).
- The convoy's UX section (which already lists a11y constraints — verify the implementer satisfied them).
- Existing accessible patterns in the repo (look at existing `Dialog`, `Form`, `Button` primitives).
## Outputs
A structured comment for the PR Health rollup:
```markdown
## A11y Audit
| Check | Status | Count |
| --- | --- | --- |
| Labels | ✅ / ❌ | <N> |
| Keyboard nav | ✅ / ❌ | <N> |
| Focus management | ✅ / ❌ | <N> |
| Color contrast | ✅ / ⚠️ | <N> |
| Semantic HTML | ✅ / ❌ | <N> |
| ARIA correctness | ✅ / ⚠️ | <N> |
| UX constraint match | ✅ / ❌ | <N> |
### Critical (must fix)
- <file:line><issue><fix>
...
### Warnings (recommended)
- <file:line><issue><fix>
...
### Notes
- ...
```
## Checklist (apply per file)
1. **Labels**: every `<input>`, `<select>`, `<textarea>`, `<button>` has either visible text, `aria-label`, or an associated `<label htmlFor=...>`.
2. **Icon-only buttons**: have `aria-label` or visually-hidden text.
3. **Keyboard navigation**: any `onClick` on a non-button/anchor element has `onKeyDown` (Enter + Space) and `tabIndex={0}` and `role="button"` (or be a real button).
4. **Focus management**: dialogs trap focus; modals return focus on close; route changes move focus to the heading.
5. **Color contrast**: text on backgrounds meets 4.5:1 (large text 3:1). Hardcoded colors that we can't measure → ⚠️.
6. **Semantic HTML**: use `<button>` not `<div onClick>`, `<nav>` for navigation, `<main>` for primary content, heading hierarchy `<h1>``<h2>``<h3>` (no skipping).
7. **ARIA correctness**: `aria-expanded` on toggles, `aria-current="page"` on active nav items, `aria-live` on async-updating regions, `role="alert"` on error messages.
8. **UX constraint match**: cross-reference the UX section's a11y constraints — did the implementer satisfy each one?
## Severity
- **Critical**: missing labels on form inputs, no keyboard handler on click-only div, missing focus trap on modal, missing alt text on informative images.
- **Warning**: heading hierarchy skip, missing `aria-current`, color-contrast that requires runtime measurement, missing live region on async updates.
## Steps
1. Get UI diff.
2. Read the convoy's UX section once to know what was promised.
3. For each changed UI file: read the current state of the file (post-diff), then walk the checklist.
4. Build the comment. Cap at 8 critical + 8 warnings.
5. If clean: ✅ across the board with a one-line note.
## What this role does NOT do
- Run axe-core in a browser (that's a CI job, see `.github/workflows/preview-smoke.yml` if present).
- Test screen readers manually — beyond static analysis scope.
- Audit non-UI changes — server / API / config diffs are out of scope.
## Multitask (audit fan-out)
Part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — none modify code or the convoy. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, pass the shared `multitask_group` id in metrics. Convention: `audit-<convoy>-<pr>`. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the audit comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-a11y-auditor convoy=<slug> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Demanding ARIA on already-semantic HTML (e.g. `aria-label` on a `<button>` that has visible text) → wrong, that's redundant.
- Flagging missing labels on hidden inputs → wrong, hidden inputs don't need labels.
- Vague feedback ("improve a11y") → wrong, every finding needs a file:line and a specific fix.

View file

@ -0,0 +1,199 @@
---
name: role-architect
description: >-
Technical plan + decomposition. Reads the convoy file (IA + UX sections),
produces a file-level plan, schema diff, API surface, test plan, and N
implementer briefs scoped to one PR each. Read + Glob + Grep, no edits.
Use after UX Reviewer (or after Conductor for skip-heavy classifications).
Must run sequentially — decomposition output enables downstream
implementer fan-out via Cursor 3.2 /multitask.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: Architect
## Trigger
After `role-ux-reviewer`, or directly after Conductor when `skip: ux` is set. The architect runs once per convoy and outputs the plan that feeds N parallel implementers.
## Inputs
- The convoy file with IA + UX sections.
- AGENTS.md and `.cursor/rules/*.mdc` for the convention contract.
- Schema map at `docs/SCHEMA_MAP.md` (Prisma repos only).
- Existing similar code identified by IA / UX sections.
## Outputs
Append a `## Architecture` section to the convoy file with:
1. **File plan** — table of `File | Action (new/modified) | Purpose`. One row per file the change touches.
2. **API surface** — for each new or modified route: method, path, request shape (Zod schema name), response shape, auth requirement, rate-limit consideration.
3. **Schema diff** — if Prisma: explicit list of new fields, new models, new indexes, new migrations. If no schema change: state that explicitly.
4. **Test plan** — what unit, integration, smoke tests are needed. Link existing test files for examples.
5. **Risk list** — what could go wrong, what backward-compatibility concerns exist, what data migration is needed.
6. **Decomposition** — table of `Brief # | Title | Files | Depends on | Estimated PR size`. One row per implementer brief.
7. **Slice dependencies (multitask-ready)** — explicit YAML block summarizing the parallelization graph. The conductor uses this to decide whether to dispatch parallel implementers via `/multitask`:
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files: [<exact list>]
- brief: 2
depends_on: []
files: [<exact list>]
- brief: 3
depends_on: [1]
files: [<exact list>]
```
Any brief whose `files:` set overlaps with a sibling's MUST be sequenced via `depends_on` — never two parallel writers on the same file.
Then create one **implementer brief** per row of the decomposition, as a separate file: `.convoys/<slug>/brief-<N>-<kebab-title>.md`. Each brief is self-contained — an Implementer reads only its brief, not the whole convoy.
## Implementer brief format
```markdown
---
convoy: <slug>
brief_number: <N>
depends_on: [<other brief numbers>]
files:
- <path/to/file1>
- <path/to/file2>
# Optional: declare files this brief deletes.
deletes:
- <path/to/file3>
# Optional: cross-brief commitments. See "Cross-brief commitments" below.
cross_brief_commitments:
- brief: <other-brief>
description: |
<one-paragraph description of the commitment>
---
# Brief <N>: <Title>
## Goal (1 sentence)
## Files in scope (do not edit anything else)
- ...
## Conventions to follow
- (cite rules + examples)
## Acceptance criteria
- [ ] ...
- [ ] tests added
- [ ] no scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
```
## Steps
1. Read the convoy file in full (frontmatter + IA + UX).
2. Read AGENTS.md and any rule with globs that match the change's file patterns.
3. If Prisma: read `docs/SCHEMA_MAP.md` for the relevant model group.
4. Build the file plan. For each file, decide new vs modified.
5. Map out the API surface (if any).
6. Compute schema diff (if any).
7. Build the test plan, linking existing test files as examples.
8. Identify risks. Be specific (e.g. *"Existing `getBookmarks()` query joins `_count`; adding to the page query may cause N+1 if not memoized"*).
9. Decompose into briefs. Aim for **<400 LOC per brief** and **independent files per brief** (parallelizable). Sequence dependencies explicitly.
10. Write each brief file.
11. **Boot the brief** (see [Boot-the-brief check](#boot-the-brief-check) below) — verify each brief's verbatim code shapes against reality before declaring the architecture complete.
12. Append the Architecture section to the convoy file.
13. Print: *"Architecture complete. <N> briefs created. Estimated PRs: <N>. Awaiting human gate 1 (plan approval) before implementers run."*
## Hand-off
Stop. **Human gate 1.** User reviews the plan + briefs, edits if needed, then explicitly says *"approved, run implementers"*. Architect does not auto-spawn implementers.
## Boot-the-brief check
Adopted from a real production retro (`scaffold-nextjs-app`, recommendation #1) — a convoy lost ~1 hour to a Brief that paired HeroUI v3 + Tailwind 3 + a JS plugin recipe; the combination was specified plausibly but didn't actually compose. Briefs that *look* compileable rarely are without a verified-against-reality pass.
Before declaring the architecture complete, the Architect must verify each brief's verbatim code shapes against a fresh checkout. This is read-only verification — the Architect does not commit code:
1. **Dep set check.** For every package added in `files:` lists or implied by code shapes, run `pnpm view <pkg> peerDependencies` (or check `package.json` if it already exists). Confirm pinned versions resolve as a coherent dependency graph: no peer-dep conflicts, no transitive `client-only` imports landing in server-component pages, no missing peer deps. If any package was released in the last ~6 months, also read its CHANGELOG / migration guide for breaking changes from the prior major version (e.g. HeroUI v2 → v3 was a Tailwind-4 rewrite that dropped the JS plugin and `<HeroUIProvider>`; the migration guide called this out and would have been free to skim).
2. **Verbatim code shape check.** For each brief whose `files:` list includes more than 1 file with verbatim code shapes, identify any of the following that's present and verify it:
- **Middleware matchers** — Next.js route groups (`(auth)`, `(workspace)`) do NOT appear in URL paths; `'/(workspace)/(.*)'` matches zero real URLs. Negative matchers excluding the public surface area are the App-Router-idiomatic pattern.
- **Prisma schema directives**`extensions = [...]`, `previewFeatures = [...]`, and other recently-added datasource flags often need both the preview-feature opt-in and a Prisma version that supports them.
- **Server-component / client-component boundaries** — any rich-a11y library (HeroUI, Mantine, Chakra, MUI) hits the `client-only` import boundary because it builds on React Aria. The pattern is `'use client'` wrapper components, not server-component imports.
- **Plugin / framework wrappers**`next-intl` requires `createNextIntlPlugin('./i18n.ts')` wrapping the `next.config.ts` export. `prisma generate` requires `previewFeatures` opt-ins for any `Unsupported` types. These are easy to miss.
3. **Cross-brief commitments check.** For each brief that lands a stub or forward declaration that another brief will resolve (e.g. Brief 4's `types/auth.d.ts` ambient declaration of `@/auth` resolved by Brief 5; Brief 5's `app/layout.tsx` stub replaced by Brief 6), document the commitment in **both** briefs' frontmatter (see [Cross-brief commitments](#cross-brief-commitments) below). Implementers reading just the depended-on brief should know the commitment exists.
If any check surfaces a problem, **revise the brief in place** before declaring the architecture complete. Do not push the verification cost down to implementers.
## Cross-brief commitments
When Brief N ships a stub, forward declaration, or temporary placeholder that Brief M (M > N) is expected to resolve, both briefs must declare the commitment in their frontmatter so future agents reading either one in isolation can see it:
```markdown
---
convoy: <slug>
brief_number: 4
depends_on: [3]
files:
- lib/auth/require-auth.ts
- types/auth.d.ts
cross_brief_commitments:
- brief: 5
description: |
`types/auth.d.ts` is a temporary ambient declaration of `@/auth`
so `tsc --noEmit` passes before Brief 5 ships `auth.ts`. Brief 5
MUST delete this file when shipping the real `auth.ts`.
---
```
```markdown
---
convoy: <slug>
brief_number: 5
depends_on: [4]
files:
- auth.ts
- lib/auth-options.ts
deletes:
- types/auth.d.ts
cross_brief_commitments:
- brief: 4
description: |
Deletes `types/auth.d.ts` (Brief 4's temporary ambient declaration
of `@/auth`). The `lib/auth/require-auth.ts` stub comment block
is also removed in this brief.
---
```
Implementer prompts can then automatically include the commitment text so the implementer knows what cross-brief debt is being paid off.
## Mid-convoy scope expansion
If the convoy's plan needs to change after `role-architect` has run (e.g. a user decision adds a new feature requirement, an implementer surfaces an inconsistency that requires re-planning), the scope-expansion PR must include:
1. The change to whichever brief(s) it affects (verbatim code shapes, `files:` list, acceptance criteria).
2. **An updated row in the convoy file's `### Decomposition` table** for every brief whose file list, LOC estimate, or dependency graph changed. Stale Decomposition tables are a documented retro finding (`scaffold-nextjs-app` retro recommendation #6) — they're how mid-convoy expansions become invisible.
3. A new dated entry in the `## Decisions (post-IA round)` section recording the user decision that drove the expansion (continue the A, B, C, ... letter sequence; reference the dated Decision in the matching `docs/04-architecture/*.md` file as the canonical authority).
## Metrics
After writing the brief files, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-architect convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Briefs >400 LOC → too big; decompose further.
- Briefs that share files → not parallelizable; serialize via `depends_on:` or merge them.
- Vague acceptance criteria ("looks right") → wrong, must be checkable.
- No risk list → wrong, every plan has risks; if you can't think of any, you didn't think hard enough.
- Auto-running implementers → forbidden, human gate is mandatory.
- Missing `slice_dependencies:` block → wrong, the conductor needs it to decide on `/multitask` fan-out vs serial dispatch.
- Skipping the Boot-the-brief check because the briefs "look obvious" → wrong, that's exactly when the dep-set or code-shape mismatches slip through.
- Cross-brief commitments declared in only one brief → wrong, the dependent brief MUST also declare it; otherwise an implementer reading the depended-on brief in isolation has no visibility into the commitment.

View file

@ -0,0 +1,118 @@
---
name: role-conductor
description: >-
Routes a new idea through the agent-context pipeline. Owns the convoy file,
classifies the work (feature / hotfix / docs / infra / server / config), sets
skip flags for stages that don't apply, recommends multitask dispatch points
for downstream roles, and hands off to the next role. Use when a new feature,
bug fix, or epic is being kicked off and the work has not yet been scoped.
multitask: single
tools: [Read, Grep, Glob, Write, Shell]
---
# Role: Conductor
The Conductor is the entry point for every convoy. It does not write code. It writes one file (`.convoys/<slug>.md`) and hands off to the IA Architect (or directly to Architect for skip-heavy classifications).
## Trigger
User says any of:
- *"Start a new convoy for ..."*
- *"Run the pipeline on ..."*
- *"Scope this idea: ..."*
Or any one-paragraph problem statement that doesn't yet have a convoy file.
## Inputs
1. **Idea**: one-paragraph problem statement.
2. **Success metric**: how we'll know it worked (Conductor must ask for this if the user didn't supply it — one round trip, not five).
## Outputs
A single file at `.convoys/<slug>.md` with this exact frontmatter:
```yaml
---
name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence>
skip:
- <flag1>
- <flag2>
status: open
created: <YYYY-MM-DD>
---
```
Below the frontmatter, four sections (each a short paragraph or todo list):
1. `## Why` — the problem, in user-impact terms.
2. `## Scope` — what's in, what's out.
3. `## Roles invoked` — which roles will run, in order.
4. `## Todos` — high-level checkboxes the next role will refine.
## Classification → skip flags (defaults)
Use these as starting points; trust the obvious cases:
| Classification | Default skip flags | Reasoning |
| --- | --- | --- |
| `feature` | (none) | Full pipeline |
| `hotfix` | `ia, ux, arch, review` | Speed over rigor; mandatory post-merge cleanup task |
| `docs-only` | `ia, ux, arch, test, visual, a11y, design, smoke, qa, flag` | Docs change docs; CI lint catches typos |
| `infra-only` | `ia, ux, arch, visual, a11y, design, smoke, qa, flag` | No UI; auditors no-op |
| `server-only` | `ia, ux, visual, a11y, design` | API or worker change; no UI |
| `config-only` | `ia, ux, arch, test, visual, a11y, design, smoke, qa, docs, flag` | env / CODEOWNERS / config file edit |
Never set: `plan-approval`, `pr-merge`, `prod-promote` (human gates are non-negotiable).
## Steps
1. Read the idea. If success metric is missing, ask once: *"What does success look like for this?"*. Wait for answer.
2. Pick a classification. If ambiguous, default to `feature`.
3. Generate kebab-slug from the idea (3-5 words).
4. Write `.convoys/<slug>.md` with frontmatter + four sections.
5. Print a one-line summary: *"Convoy `<slug>` created (classification: `<X>`, skipping: `<flags>`). Next role: <role-X>."*
## Hand-off
Hand off by message to the user, not by spawning another role automatically. The user runs the next role manually (they can paste *"role-ia-architect"* into the chat or open a new chat and reference the convoy). This keeps the human in the loop for the early stages where direction is most plastic.
## Multitask dispatch recommendations
The Conductor doesn't run anything in parallel itself, but it **tells the user where parallelism is safe downstream** so they can use Cursor 3.2 `/multitask` when appropriate. Include these recommendations in the hand-off summary based on the classification:
| Classification | Recommended `/multitask` dispatch points |
| --- | --- |
| `feature` | After architect: dispatch implementers for all briefs with `depends_on: []` AND disjoint `files:` in parallel. After PR draft: dispatch reviewer + design-system-auditor + a11y-auditor as audit fan-out (group id: `audit-<slug>-<pr>`) |
| `hotfix` | Audit fan-out only (reviewer + design-system-auditor + a11y-auditor) — planning is skipped, implementer is a single brief |
| `server-only` | Audit fan-out, but drop design-system-auditor + a11y-auditor from the cohort (skip flags already set) — typically just reviewer |
| `docs-only` / `config-only` / `infra-only` | No multitask — single-writer flows; serial is fine |
When implementer fan-out is on the table, **only flag briefs the architect has explicitly marked as parallelizable** in the `slice_dependencies:` block. If the architect didn't supply that block, recommend serial dispatch and note that the architect output is incomplete.
See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) for the full guardrail set.
## Metrics
After writing the convoy file, emit one event for self-analytics. Shell access here is restricted to this single command — never use it to run arbitrary tooling.
```bash
bash scripts/log-convoy-event.sh \
role=role-conductor \
convoy=<slug> \
classification=<feature|hotfix|docs-only|infra-only|server-only|config-only> \
skip_flags=<comma,separated> \
duration_s=<seconds-since-trigger>
```
If `scripts/log-convoy-event.sh` does not exist (L3 not installed), skip silently — analytics is opt-in.
## Anti-patterns
- Conductor writes code → wrong, that's Implementer.
- Conductor sets `skip: pr-merge` → forbidden, human gates are non-negotiable.
- Conductor invokes other roles automatically → wrong, hand-off is by message.
- Conductor produces more than one file → wrong, output is exactly `.convoys/<slug>.md`.

View file

@ -0,0 +1,102 @@
---
name: role-design-system-auditor
description: >-
Audits a UI diff against the repo's design system. Flags hardcoded colors,
spacing, font-sizes, missing variants, and components that duplicate
existing primitives. Read-only. Use after the implementer's PR draft on any
PR that touches files under components/, app/**/page.tsx, or
app/**/layout.tsx. Safe to run in parallel with role-reviewer +
role-a11y-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: Design System Auditor
## Trigger
After `role-reviewer` on PRs that touch UI files. Skip when convoy frontmatter has `skip: design`.
## Inputs
- The PR diff.
- Design tokens: `tailwind.config.ts`, `app/globals.css` CSS variables (or `src/styles/`).
- Component primitives directory: `components/ui/` (or `src/components/ui/`).
- Any rule scoped to `components.mdc`, `styling.mdc`, `design-system.mdc`.
## Outputs
A structured comment for the PR Health rollup:
```markdown
## Design System Audit
| Check | Status | Count |
| --- | --- | --- |
| Token violations | ✅ / ❌ | <N> |
| Duplicate primitives | ✅ / ❌ | <N> |
| Missing variants | ✅ / ❌ | <N> |
| Inline styles | ✅ / ❌ | <N> |
### Token violations
<file:line> — used `<value>` (use token `<name>` instead)
...
### Duplicate primitives
<NewComponent.tsx> duplicates <ExistingComponent.tsx>; consider reusing.
...
### Other findings
- ...
```
## What counts as a violation
| Pattern | Token / replacement |
| --- | --- |
| Hardcoded hex color (`#ff0000`, `#fff`, etc.) | Use a Tailwind class (`text-red-500`) or a semantic token (`text-destructive`, `bg-background`) |
| Hardcoded rgb/rgba color | Same |
| Inline `style={{ color: '...' }}` | Same |
| Custom CSS for spacing values not on the Tailwind scale (e.g. `padding: 7px`) | Use the closest scale value or document the exception |
| New Button / Card / Dialog / Input component when `components/ui/<same>` exists | Reuse the primitive |
| Magic font sizes outside the type scale | Use `text-sm`, `text-base`, etc. |
| `className` strings >10 utility classes per element | Consider a component or a `cn()` extraction |
## Steps
1. Get the PR diff. Filter to UI files (`*.tsx`, `*.css`, `*.scss`).
2. Read `tailwind.config.ts` and `app/globals.css` (or equivalents) once to load the token vocabulary.
3. `Glob` `components/ui/**/*.tsx` to enumerate existing primitives.
4. For each changed UI file:
- `Grep` for hex/rgb literals → token violations.
- `Grep` for `style={{` → inline styles.
- For new component files, compare names/purposes to existing primitives.
5. Build the structured comment. Cap at 10 most-impactful findings.
6. If no violations: report ✅ across the board with a one-line note.
## Hand-off
Comment posted. Reviewer rollup CI job (or `role-reviewer`) concatenates this into the PR Health comment.
## Multitask (audit fan-out)
Part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — none modify code. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, pass the shared `multitask_group` id in metrics. Convention: `audit-<convoy>-<pr>`. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the audit comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-design-system-auditor convoy=<slug> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Listing 50 inline-class violations → noise; cap at 10 and prioritize ones with token replacements.
- Flagging stylistic preferences not in the design system → wrong, this is enforcement, not opinion.
- Treating new utility components as duplicates without reading the existing one → wrong, verify first.
- Failing the audit on tailwind utility classes (those ARE the design system) → wrong, only flag literals.

View file

@ -0,0 +1,83 @@
---
name: role-doc-writer
description: >-
Updates documentation after a feature merges to develop. Adds CHANGELOG
entries, updates AGENTS.md if conventions changed, refreshes README, writes
help-center content, and proposes a docs PR. Use after PR merge (gate 2)
before prod promote (gate 3). Skip when convoy frontmatter has skip: docs.
Must run sequentially — writes a single docs PR.
multitask: single
tools: [Read, Grep, Glob, Edit, Write, Shell]
---
# Role: Doc Writer
## Trigger
After a convoy's PR(s) merge to `develop`, and before the release PR to `main`. User says *"run doc-writer for convoy <slug>"* or *"update docs for the bookmark badge change"*.
## Inputs
- The merged convoy file (`.convoys/<slug>.md`).
- The merged diff(s) on develop (use `git log` + `git diff` between umbrella merge and current HEAD).
- Existing CHANGELOG.md, DEVELOPER_CHANGELOG.md, AGENTS.md, README.md, and `docs/help/` (or equivalent).
## Outputs
A docs-only PR that may touch:
| File | When to update |
| --- | --- |
| `CHANGELOG.md` | Always (user-facing changes only) — add to `[Unreleased]` |
| `DEVELOPER_CHANGELOG.md` | When API, schema, or breaking change happened |
| `AGENTS.md` | When a new convention emerged or an existing one shifted |
| `.cursor/rules/<topic>.mdc` | When a new convention belongs in a glob-scoped rule |
| `README.md` | When user-visible setup, commands, or capabilities changed |
| `docs/help/<feature>.md` | When end users need new help content |
| `docs/SCHEMA_MAP.md` (regenerate) | When Prisma schema changed — run `npm run schema:map` |
## Steps
1. Read the convoy file and the merged diff.
2. Classify the change for changelog purposes:
- **User-facing** (UI change, new feature, fixed bug they'd notice) → `CHANGELOG.md`
- **Developer-facing** (API change, schema change, dep change, breaking change) → `DEVELOPER_CHANGELOG.md`
- **Both** → both files, written for the right audience in each
3. Draft the CHANGELOG entry. Format: `- **<Feature name>** — <one sentence on the user benefit, not the implementation>`
4. Decide if AGENTS.md needs an update. Trigger conditions:
- New convention introduced (e.g. *"all bookmark queries now use _count.bookmarks"*)
- Existing convention shifted (e.g. *"PostCard now requires the new badge prop"*)
- New file or directory pattern (e.g. *"new lib/flags/ directory"*)
5. Decide if a new `.cursor/rules/` file is warranted. Threshold: the convention applies to >3 future PRs and is glob-scopeable.
6. Decide if README needs an update (rare).
7. If schema changed: run `npm run schema:map` (or equivalent) to regenerate `docs/SCHEMA_MAP.md`. Commit the regenerated file in the same PR.
8. Write all updates as a single docs-only PR. Use the existing PR template; add `<!-- pipeline: skip a11y, design-system, smoke -->` since it's docs-only.
9. Print: *"Docs PR drafted. Files changed: <list>. Awaiting human review."*
## Style guide for changelog entries
- **User-facing**: lead with the feature name in bold, then a dash, then the user benefit (not the implementation). Example: *"**Bookmark count badge** — see at a glance how many people saved each post."*
- **Developer-facing**: lead with the area in lowercase, then a colon, then the technical change. Example: *"posts API: `_count.bookmarks` now included in the default `select` for the home feed query."*
- Keep entries to one sentence. Link to the PR if the change needs more context.
- Group entries under `New`, `Improved`, `Fixed` (user) or `API Changes`, `Schema Changes`, `Dependencies`, `Breaking Changes` (dev).
## Hand-off
Docs PR opened. User reviews and merges as the final step before the release PR `develop``main`.
## Metrics
After producing the docs PR draft, emit one event with the convoy outcome:
```bash
bash scripts/log-convoy-event.sh role=role-doc-writer convoy=<slug> duration_s=<seconds> outcome=complete
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Writing implementation-detail changelog entries to the user-facing file → wrong, audience matters.
- Updating AGENTS.md for one-off changes → wrong, AGENTS.md is for conventions, not history.
- Forgetting to regenerate SCHEMA_MAP.md after a Prisma change → wrong, schema docs drift fast.
- Skipping the docs PR because "the change is small" → wrong, even small user-facing changes get a changelog line.

View file

@ -0,0 +1,68 @@
---
name: role-ia-architect
description: >-
Information architecture pass. Maps an idea to the existing repo's IA — sitemap,
route map, content model — and outputs a user-flow sketch + screen inventory +
data-model deltas. Read-only. Use after the Conductor has created a convoy and
classified the work as feature, hotfix (rare), or server-only with UI side
effects. Must run sequentially — output feeds role-ux-reviewer.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: IA Architect
## Trigger
Conductor hands off to this role for any classification that includes UI work or new routes/pages. Skip when convoy frontmatter has `skip: ia`.
## Inputs
- The convoy file (`.convoys/<slug>.md`).
- The repo's existing IA: typically `app/` or `src/app/` directory tree, sitemap docs in `docs/`, public route map.
- Existing AGENTS.md and any rule scoped to navigation / routing.
## Outputs
Append a `## IA` section to the convoy file. The section contains:
1. **Affected routes** — bullet list of paths created, modified, or impacted. Mark each as `[new]`, `[modified]`, or `[impacted]`.
2. **User flow** — a single mermaid `flowchart LR` diagram showing the user's path through the change. Keep to ≤8 nodes.
3. **Screen inventory** — table of `Screen | Path | New/modified | Notes`. One row per screen.
4. **Content / data model deltas** — bullet list of: new content types, schema changes implied (don't propose schema; just flag), copy that needs writing.
5. **Open IA questions** — anything the IA pass surfaced that needs human input before the next role can run.
Write the section — do **not** rewrite the convoy frontmatter, do **not** add code.
## Steps
1. Read the convoy file in full.
2. Read the existing route map: `Glob` for `app/**/page.tsx`, `app/**/route.ts`, `src/pages/**/*.tsx`. Pick the matching one for this stack.
3. Identify which existing routes the change touches.
4. Sketch the user flow as mermaid. Prefer concrete page names over generic boxes.
5. Build the screen inventory. For each screen, note whether it's new or existing.
6. Identify content/data deltas. Don't design the schema; just say *"new field on Bookmark for ...?"*.
7. List open questions if any.
8. Append the IA section to the convoy file.
9. Print: *"IA pass complete. <N> screens, <M> routes affected. Next role: role-ux-reviewer (or role-architect if UX is skipped)."*
## Hand-off
Message the user. They run the next role.
## Metrics
After appending your IA section, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-ia-architect convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Proposing a schema → wrong, that's Architect.
- Designing components → wrong, that's UX Reviewer + Architect.
- Writing code → wrong.
- Mermaid diagram with >10 nodes → too detailed; this is IA, not implementation.

View file

@ -0,0 +1,99 @@
---
name: role-implementer
description: >-
Builds one PR worth of code from one architect brief. Strictly scoped to the
files listed in the brief; never widens scope. Writes code, writes tests,
runs lint, and proposes the PR (does not open it). Use after the architect's
plan is approved by human gate 1, once per brief. Multiple implementers can
run as a Cursor 3.2 /multitask fleet IFF their briefs declare empty
depends_on AND disjoint files: lists; each implementer gets its own worktree.
multitask: per-brief
tools: [Read, Grep, Glob, Edit, Write, Shell]
---
# Role: Implementer
## Trigger
User runs this role and references a specific brief: *"Run implementer on `.convoys/<slug>/brief-<N>-...md`"*. Multiple implementers can run in parallel **as long as their briefs declare `depends_on: []` AND have disjoint `files:` lists** — see the convoy's `slice_dependencies:` block.
Preferred parallel-dispatch path on Cursor 3.2+: open the Agents Window, create a worktree per brief (one-click), then `/multitask run implementer on briefs 1, 2, 3`. Cursor isolates each subagent in its own worktree automatically. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern B.
## Inputs
- Exactly one brief file (`.convoys/<slug>/brief-<N>-...md`).
- The convoy's IA / UX / Architecture sections (read once for context).
- AGENTS.md and matching `.cursor/rules/*.mdc`.
- Existing example files cited in the brief.
## Outputs
1. Code changes to **only** the files listed in the brief's `files:` frontmatter.
2. Tests added per the brief's acceptance criteria.
3. A PR draft posted to chat (not opened on GitHub).
## Steps
1. Read the brief in full. Confirm understanding of scope.
2. Read the convoy file's IA / UX / Architecture sections (one Read each).
3. Read each file in the brief's `files:` list (existing files only — new files have no content yet).
4. Read 1-2 example files cited in the brief.
5. Make the edits. Stay strictly inside `files:`.
6. Write the tests.
7. Run lint: `npm run lint` (or repo equivalent — check `package.json` scripts).
8. Run tests: `npm test` (or repo equivalent).
9. If lint or tests fail, fix and re-run. Three attempts max; if still failing, stop and report.
10. Produce a PR draft for the user:
```markdown
## PR draft: <brief title>
<!-- pipeline: brief=<N>, convoy=<slug> -->
### Summary
- 2-3 bullets on what changed and why
### Files changed
- (list)
### Acceptance criteria
- [x] ...
- [x] tests added (link to test files)
- [x] no scope expansion
### Test plan
- ...
### Notes
- Anything the reviewer should know
```
User copies the PR draft into the GitHub PR creation flow.
## Hard rules
- **Never edit files outside the brief's `files:` list.** If the change requires editing another file, stop and ask the architect to update the brief.
- **Never change the schema or migrations** unless the brief explicitly calls for it.
- **Never disable tests** to make them pass. Fix the test or fix the code.
- **Never bypass auth, validation, or error helpers** to ship faster. Use the conventions in the rules.
## Hand-off
The user reviews the PR draft, opens the PR via `gh` or Cursor's UI. Reviewer + auditors run on the open PR.
## Metrics
After producing the PR draft, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-implementer convoy=<slug> brief=<N> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Quietly editing a file not in `files:` because it "needed it" → forbidden, escalate to architect instead.
- Skipping tests because "it's obvious" → wrong.
- Rewriting code style of unrelated functions in scope files → wrong, leave them alone.
- Opening the PR yourself via `gh` → wrong, stop at PR draft.

View file

@ -0,0 +1,101 @@
---
name: role-reviewer
description: >-
Self-review pass on a PR before requesting human review. Compares the diff
against the architect's brief, checks convention compliance, flags scope
expansion, security concerns, regression risk, and test coverage gaps.
Read-only. Outputs a structured PR comment. Use after the implementer's
PR draft and before the human merges. Safe to run in parallel with
role-design-system-auditor + role-a11y-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: Reviewer
## Trigger
After `role-implementer` produces a PR draft, OR on any open PR when the user says *"run reviewer on PR #N"* or *"review this diff"*.
## Inputs
- The PR diff (via `git diff` or `gh pr diff <N>`).
- The architect brief the implementer worked from (`.convoys/<slug>/brief-<N>-...md`).
- AGENTS.md and matching rules.
## Outputs
A single Markdown comment ready to paste into the PR (or to the user). Use this exact format so the PR Health rollup CI job can parse it:
```markdown
## Reviewer Report
| Check | Status | Notes |
| --- | --- | --- |
| Scope match | ✅ / ⚠️ / ❌ | |
| Conventions | ✅ / ⚠️ / ❌ | |
| Security | ✅ / ⚠️ / ❌ | |
| Regression risk | low / medium / high | |
| Test coverage | ✅ / ⚠️ / ❌ | |
| Documentation | ✅ / ⚠️ / ❌ | |
### Findings
- 🔴 **Critical** (must fix before merge): ...
- 🟡 **Suggestion** (consider): ...
- 🟢 **Nice to have** (optional): ...
### Approval recommendation
- approve / request-changes / comment-only
```
## Steps
1. Read the brief. Note the `files:` list and acceptance criteria.
2. Get the diff. Compare files-changed against `files:` — flag any expansion.
3. For each acceptance criterion, search the diff for evidence it's satisfied.
4. Check conventions against AGENTS.md and matching rules. Common gotchas:
- Auth/error helpers used vs. ad-hoc `NextResponse.json({ error: ... }, { status: ... })`
- Zod validation used for any new request body
- Prisma `select`/`include` not over-fetching
- Multi-tenant scoping if applicable (see `.cursor/rules/auth-tenancy.mdc` if present)
5. Security pass: any new endpoint without `requireAuth` / `requireAdmin`? Any user input flowing into a query without validation? Any secret in code?
6. Regression risk: does this change a function with many callers? Use `Grep -r "<function name>"` to estimate blast radius.
7. Test coverage: did the implementer add tests per the brief? Are they testing behavior or implementation?
8. Documentation: AGENTS.md or rule needs updating? Changelog entry needed under `[Unreleased]`?
9. Write the structured comment.
## Severity guidance
- 🔴 **Critical** is reserved for: security holes, broken builds, scope expansions outside the brief, missing auth on protected routes, breaking schema changes without migration.
- 🟡 **Suggestion** is for: convention drift, missing edge cases, unclear naming, over-fetching, missing test for a non-trivial path.
- 🟢 **Nice to have** is for: stylistic preferences, optional refactors, doc nits.
If you're tempted to mark something Critical and you're not sure, downgrade to Suggestion. The reviewer's credibility comes from sparing use of red.
## Hand-off
User reads the report. If approve → human gate 2 (merge). If request-changes → user re-runs implementer with the findings.
## Multitask (audit fan-out)
This role is part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — they never modify code or the convoy file. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, include the shared `multitask_group` id in the metrics call. The id convention is `audit-<convoy>-<pr>` (e.g. `audit-bookmark-badge-PR123`). See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the review comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-reviewer convoy=<slug> brief=<N> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Suggestions list of 20 nits → noise; max 5 actionable items.
- Approving a PR with scope expansion → wrong, that's a Critical.
- Re-running implementation work yourself → wrong, request changes and let implementer fix.
- Inventing acceptance criteria not in the brief → wrong, the brief is the contract.

View file

@ -0,0 +1,68 @@
---
name: role-ux-reviewer
description: >-
UX / IX review pass against the existing design system. Identifies which
existing components and patterns to reuse, calls out anti-patterns to avoid,
and lists a11y constraints that must be satisfied. Read-only. Use after
IA Architect on any feature with UI changes. Must run sequentially — refines
the IA section, feeds role-architect.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: UX Reviewer
## Trigger
After `role-ia-architect` for any classification that includes UI work. Skip when convoy frontmatter has `skip: ux`.
## Inputs
- The convoy file (with the IA section appended by the previous role).
- Existing UI primitives directory (typically `components/ui/` or `src/components/ui/`).
- Design tokens (typically `tailwind.config.ts`, `app/globals.css` CSS variables).
- Any rule scoped to `components.mdc`, `styling.mdc`, or `design-system.mdc`.
## Outputs
Append a `## UX` section to the convoy file with:
1. **Existing components to reuse** — bullet list of `<ComponentName>` (`path/to/file.tsx`) for each reusable primitive the screens need. Be specific — name the file.
2. **Existing patterns to follow** — referenced rules and example screens that solve a similar problem (e.g. *"PostCard.tsx is the canonical card pattern; use the same Badge primitive there"*).
3. **A11y constraints** — bullets enumerating: required ARIA labels, keyboard navigation paths, focus management, color-contrast requirements specific to this change.
4. **Interaction patterns** — short list: hover/focus/active states, optimistic UI, error states, empty states, loading states. Mark each as `required` or `nice-to-have`.
5. **Anti-patterns to avoid** — explicit list of what NOT to do (e.g. *"Don't add a new color outside the design tokens for the badge background"*).
6. **Mobile / responsive notes** — if the change has UI, this section is mandatory. If headless/server-only, note that.
## Steps
1. Read the convoy file. Find the IA section.
2. For each screen in the IA inventory:
- `Glob` for relevant existing components in `components/ui/` (or equivalent).
- Identify the closest existing pattern by reading 1-3 example files.
3. Read the design tokens once (one Read of `tailwind.config.ts` or `app/globals.css`).
4. Author the UX section. Be opinionated. Pick one pattern, not three options.
5. Call out a11y requirements explicitly — don't say *"follow a11y best practices"*; say *"requires aria-label on the toggle button when collapsed"*.
6. Append section to convoy file.
7. Print: *"UX pass complete. Reuse: <N> primitives. A11y constraints: <M>. Next role: role-architect."*
## Hand-off
Message the user.
## Metrics
After appending your UX section, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-ux-reviewer convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Suggesting new components when an existing one fits → wrong, this role's job is reuse.
- Vague a11y guidance ("follow WCAG") → wrong, list specific requirements.
- Three alternatives — pick one → wrong, pick one with reasoning.
- Designing the schema or API → wrong, that's Architect.

View file

@ -0,0 +1,106 @@
---
description: Conventions for Next.js Pages-router API route handlers in tcg-vault
globs: pages/api/**/*.js
---
# API Route Conventions
Pages router handlers; `(req, res)` signature; Vercel serverless functions.
## Authentication & Authorization
Two paths are in use; both go through `lib/permission-middleware.js`.
```js
// 1. Generic auth — every protected route
import { getUserFromRequest } from '../../lib/permission-middleware';
export default async function handler(req, res) {
const user = await getUserFromRequest(req);
if (!user) return res.status(401).json({ error: 'Authentication required' });
// user = { userId, email, role }
// ...
}
```
```js
// 2. Collection-scoped auth — when the route operates on a specific collection
import { withCollectionPermission } from '../../lib/permission-middleware';
async function handler(req, res) {
// req.user and req.permission are populated by the wrapper
const { userId, role } = req.user;
}
export default withCollectionPermission('viewer')(handler);
// 'viewer' | 'editor' | 'owner' — checks owner OR is_public OR explicit collection_permissions row
```
**CRITICAL — known bug:** `getUserFromRequest` currently has a development-mode fallback that returns a hardcoded admin user when no Bearer token is present. Until that's fixed, callers MUST also assert `req.headers.authorization` exists when the route is sensitive (admin operations, deletes). See `AGENTS.md` Gotcha #2.
## Request validation
No schema validator is installed (no zod / yup / valibot). Validate manually:
```js
const { name, game } = req.body || {};
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'name is required' });
}
if (!['mtg', 'pokemon', 'lorcana'].includes(game)) {
return res.status(400).json({ error: 'invalid game' });
}
```
When adding zod (planned in `.convoys/`), define schemas at the top of the file.
## Method gating
Reject unsupported methods explicitly — Next.js will otherwise call the handler for any method:
```js
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
```
## Error handling
Wrap the handler body in `try/catch`. Never throw unhandled — leaks stack traces in the Vercel response.
```js
export default async function handler(req, res) {
try {
// ...
} catch (err) {
console.error('[POST /api/collections]', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
```
## Database access
- **Prefer:** `import { sql } from '@vercel/postgres'` and tagged templates: `` await sql`SELECT … WHERE id = ${id}` ``.
- **Avoid:** `import { db } from '../../lib/database'`. Its `query(str, params)` API uses `sql.unsafe` after manual interpolation — SQL-injection vector. Slated for removal in a convoy.
- **Always select narrow columns** — don't `SELECT *` from `cards` (large `oracle_text`, `colors` JSONB).
## Response shape
- Success (GET / read): `res.status(200).json({ data: ... })` OR direct payload — codebase is inconsistent; match the surrounding route's existing shape.
- Created (POST): `res.status(201).json({ data: ... })`.
- Errors: `res.status(<4xx|5xx>).json({ error: string, details?: unknown })`.
## Activity logging
Any handler that mutates a collection must call `logCollectionActivity`:
```js
import { logCollectionActivity } from '../../lib/permission-middleware';
await logCollectionActivity(collectionId, userId, 'card_added', { cardId, quantity });
```
## Dev/test endpoints
`pages/api/simple.js`, `pages/api/test-auth.js`, `pages/api/test-db.js`, `pages/api/setup-database.js` — these are dev-only endpoints currently shipped to prod. Don't add more. Existing ones should be deleted or admin-gated before public launch.

View file

@ -0,0 +1,56 @@
---
description: Auth model + permission model for tcg-vault (JWT + collection roles)
globs: pages/api/**/*.js,lib/*.js,components/*.js,pages/*.js
---
# Auth + permissions
There are three parallel client-side auth implementations and one server-side helper. New code should use the canonical set listed below; don't proliferate variants.
## Canonical surface (use these)
| Concern | Module |
| --- | --- |
| Server: extract user from request | `lib/permission-middleware.js::getUserFromRequest` |
| Server: gate a collection route | `lib/permission-middleware.js::withCollectionPermission` |
| Server: log collection mutation | `lib/permission-middleware.js::logCollectionActivity` |
| Server: token / password primitives | `pages/api/auth-utils.js` (`generateToken`, `verifyToken`, `hashPassword`, `verifyPassword`) |
| Client: hook | `lib/use-auth.js::useAuth` |
| Client: route protection | `components/ProtectedRoute.js` |
| Client: admin route protection | `components/AdminProtected.js` |
## Legacy (do not extend)
- `lib/auth-context.js::AuthProvider` + `useAuth` — older context. Still wired in `pages/_app.js`; left in place for compatibility. Don't add new consumers.
- `lib/admin-auth.js::AdminProvider` + `useAdmin` + `useIsAdmin` — parallel admin context. Same story.
A convoy is planned to collapse these three into one provider + one hook.
## Token model
- JWT in localStorage under the key `auth_token`.
- Signed with `JWT_SECRET` (HS256), 7-day expiry, payload `{ userId, email, role }`.
- Sent on every authenticated fetch as `Authorization: Bearer <token>`.
- Verified server-side with `jsonwebtoken.verify(token, JWT_SECRET)`.
**JWT_SECRET MUST be set in the deploy environment.** Seven files default it to a string literal if unset; that defeats signing.
## Roles
Two role surfaces are in play:
1. **User role** — `users.role` column, values `'user'` or `'admin'`. Admin gates `/admin/*` pages and admin-only API endpoints.
2. **Collection role** — `collection_permissions.role` (`viewer` / `editor` / `owner`) + `collections.is_public` (anonymous viewer access). Resolved by `checkCollectionPermission` in priority order: owner → public-viewer → explicit row.
When introducing a new permission tier, update both `checkRolePermission`'s hierarchy AND every gate that reads `is_public`.
## Authentication state on the client
`useAuth()` returns `{ user, loading, login, logout, refresh }`. `user === null` means logged out; `loading === true` means token verification in flight. Always render against `loading === false` before deciding to redirect.
## Server-side authorization patterns
- **Owner-only** (delete, settings): inside the handler, `if (user.userId !== resource.user_id) return res.status(403)`.
- **Editor-or-owner**: use `withCollectionPermission('editor')`.
- **Public read**: use `withCollectionPermission('viewer')` — handles `is_public` and explicit-permission case.
- **Admin-only**: check `user.role === 'admin'` directly; consider extracting `withAdmin()` if a third call site appears.

View file

@ -0,0 +1,59 @@
---
description: Neon Postgres conventions + the until-we-have-migrations workflow
globs: pages/api/**/*.js,lib/database.js,scripts/**/*.js
---
# DB + schema
## Clients
Two are installed (`@vercel/postgres` + `@neondatabase/serverless`); they point at the same Neon Postgres. New code uses `@vercel/postgres` (tagged-template SQL).
```js
import { sql } from '@vercel/postgres';
const { rows } = await sql`
SELECT id, name, set_name
FROM cards
WHERE game = ${game}
AND set_code = ${setCode}
LIMIT 50
`;
```
**Never** use `lib/database.js`'s `db.query(string, params)` API for new code — it interpolates params into a string and then calls `sql.unsafe()`, which is a SQL-injection vector. Marked for removal in `.convoys/`.
## Schema source of truth
`scripts/setup-neon-db.js` is the bootstrap DDL — idempotent (`CREATE TABLE IF NOT EXISTS`). Real schema state lives in Neon. Until a proper migration tool is adopted:
- **Adding a column**: new dated script under `scripts/migrations/YYYY-MM-DD-<slug>.js` (folder TBD; until then, top-level `scripts/add-*.js` named for the change).
- **Document** the change in `docs/SCHEMA_MAP.md`.
- **Never** edit a script that has already been run in prod.
## Tables (current)
| Table | Owner | Notes |
| --- | --- | --- |
| `users` | core | `(id, email UNIQUE, password, role, created_at, …)` |
| `cards` | core | Big — `oracle_text TEXT`, `colors JSONB`. Don't `SELECT *`. |
| `user_cards` | per-user | `(user_id, card_id, quantity, condition, is_foil)` — UNIQUE on tuple |
| `collections` | per-user | `is_public BOOLEAN`, `slug`, `tags`, `image_url`, `system_collection` |
| `collection_cards` | join | `(collection_id, card_id, quantity)` UNIQUE |
| `collection_permissions` | per-user | `(collection_id, user_id, role, status)` — `viewer\|editor\|owner` |
| `collection_activity` | log | `(collection_id, user_id, action, details JSONB, created_at)` |
| `decks` / `deck_cards` | per-user | Mirror of collections |
| `favorites` | per-user | `(user_id, card_id)` |
| `invitations` | per-collection | Pending share requests |
Full map: [`docs/SCHEMA_MAP.md`](../../docs/SCHEMA_MAP.md). Regenerate by re-reading `setup-neon-db.js` + every `add-*.js` script that's been run.
## Indexing reminders
- `cards.scryfall_id` is UNIQUE — use it for dedupe on import.
- `users.email` is UNIQUE — case-insensitive collation NOT set; lowercase before query/insert.
- `collections.slug` should be UNIQUE per user; verify with `lib/slug-utils.js::generateUniqueSlug` before insert.
## Transactions
Neon HTTP doesn't support multi-statement transactions across separate `sql` calls — each call is its own connection. For multi-table writes that need atomicity, use Neon's `sql.transaction([query1, query2])` array form OR refactor to a single SQL statement with CTEs. The current codebase has several non-atomic multi-step inserts that should be flagged.

View file

@ -0,0 +1,37 @@
---
description: Files and directories agents must not edit, and should not use as context examples
alwaysApply: true
---
# No-go zones
Do not edit, refactor, or quote as context examples. If you think you need to change one of these, stop and ask.
## Generated / vendored
- `node_modules/` — generated dependency tree
- `.next/` — Next.js build output
- `.vercel/` — Vercel CLI local config + build cache
- `out/`, `build/` — build outputs if present
## Append-only / historical
- `components/Layout.js.backup` — legacy snapshot; delete with a real PR, never edit
- `scripts/add-*.js`, `scripts/fix-*.js`, `scripts/seed-*.js` — historical migration / seed jobs already executed. Write a NEW dated script (or a real migration) for further schema changes; never edit ones that already ran.
## Secrets / credentials
- `.env`, `.env.local`, `.env.development.local`, `.env.test.local`, `.env.production.local`
- Anything matching `.env*.local`
- Never commit `JWT_SECRET`, `POSTGRES_URL`, `RESEND_API_KEY`, `BLOB_READ_WRITE_TOKEN`, `GEMINI_API_KEY`, OpenAI/Anthropic keys.
## Local-only / per-developer
- `.code-review-graph/` — local MCP graph index (only if `user-code-review-graph` is installed)
- `.convoys/.metrics.jsonl` — per-developer convoy analytics (gitignored by default)
## Editing rules of thumb
- **Schema changes:** until a proper migration tool lands, document the change in a new dated script under `scripts/migrations/YYYY-MM-DD-<slug>.js` (folder TBD). Do NOT edit `scripts/setup-neon-db.js` in place — it's idempotent and meant for first-time setup only.
- **Auth refactors:** `lib/permission-middleware.js`, `pages/api/auth-utils.js`, `lib/auth-context.js`, `lib/admin-auth.js`, and `lib/use-auth.js` form a deliberately documented mess. Tighten them inside a single convoy; don't cherry-pick.
- **Card-import jobs:** `pages/api/cards/import-*.js` hit external APIs with rate limits. Don't run them ad-hoc against prod data; use staging.

View file

@ -0,0 +1,30 @@
---
description: Schema map — when an agent needs to know what columns exist on which table
globs: pages/api/**/*.js,scripts/**/*.js,lib/database.js,lib/permission-middleware.js
---
# Use the schema map
Before writing any SQL that touches a column you haven't verified:
1. **Read [`docs/SCHEMA_MAP.md`](../../docs/SCHEMA_MAP.md)** — it's the curated reference.
2. If the column isn't documented there, search the migration scripts:
```
rg "<column_name>" scripts/
```
3. If still no match, **stop and ask** — don't guess column names; Postgres won't be polite.
## When to update the schema map
After running any `scripts/add-*.js` / `scripts/fix-*.js` or any direct `ALTER TABLE`, update the matching table section in `docs/SCHEMA_MAP.md` in the same PR. The CI gate `schema-map-fresh` will fail if a `scripts/add-*.js` is added without a corresponding update.
## Common reference
- **Auth check inside an API route** → `users` (id, email, role)
- **Card lookup** → `cards` (id, name, scryfall_id UNIQUE, game)
- **What a user owns** → `user_cards` (user_id, card_id, quantity, is_foil)
- **Collection card list** → `collection_cards` JOIN `cards`
- **Permission check** → `collection_permissions` (collection_id, user_id, role, status='active')
- **Activity feed** → `collection_activity` (action, details JSONB)

View file

@ -0,0 +1,61 @@
---
description: Tailwind + CSS-variable theming, component patterns, and a11y reminders
globs: components/**/*.js,pages/**/*.js
---
# UI + theming
## Theming model
Two systems coexist:
1. **Tailwind utility classes** (`text-gray-700`, `bg-white`, `dark:bg-gray-800`) — used for layout, spacing, and structural styles.
2. **CSS variables** (`var(--bg-primary)`, `var(--text-primary)`, `var(--accent-ember)`, `var(--accent-flame)`, `var(--border)`) — used for colors that need to switch with theme (light/dark).
**Don't mix and match within a single style declaration.** Pick one source per property. Generally:
- Backgrounds + text colors: CSS variables (via `style={{ backgroundColor: 'var(--bg-primary)' }}`).
- Spacing, sizing, flex, grid: Tailwind classes.
- Focus rings: CSS variables for color, Tailwind for everything else (`focus:outline-none focus:ring-2 focus:ring-offset-2` + `'--tw-ring-color': 'var(--accent-ember)'`).
Theme switching: `useTheme()` from `lib/theme-context.js`. Provider is wired in `pages/_app.js`.
## Component conventions
- Functional components, default-exported by name (`export default function CardItem(...)`).
- Props destructured in the signature with defaults: `function Layout({ children, user = null, showSearch = false })`.
- **Avoid hardcoded default values for `user` props.** `Layout` currently defaults `user` to a real email address — every page passing through Layout should pass `user` explicitly. New components must default to `null` and render a logged-out state.
## Layout
Pages render inside `<Layout user={user} showSearch={...}>{children}</Layout>`. Layout owns:
- Desktop sidebar + mobile bottom-nav (`components/MobileNavigation.js`).
- Theme toggle.
- User profile dropdown.
Don't duplicate navigation in a page — extend `NavigationContent` inside Layout instead.
## Accessibility
- Every interactive element needs a label: `aria-label`, `aria-labelledby`, or visible text.
- Modals need `role="dialog"`, `aria-modal="true"`, and focus management (trap focus + restore on close).
- Color contrast: stick to the documented theme tokens — they're tuned for AA.
- Keyboard: every `onClick` on a non-`<button>` needs `tabIndex={0}` + `onKeyDown` for Enter/Space.
## Common UI patterns to reuse
| Need | Where |
| --- | --- |
| Card grid item | `components/CardItem.js` |
| Bulk-action toolbar | `components/BulkSelectionToolbar.js` |
| Modal | `components/CollectionSelectionModal.js`, `components/ShareModal.js` |
| Image upload | `components/UploadImageModal.js` |
| Camera scanner | `components/CameraScanner.js` |
| Auth-required wrapper | `components/ProtectedRoute.js` |
| Admin-only wrapper | `components/AdminProtected.js` |
| Public-or-auth wrapper | inline in `pages/cards.js` (`PublicCardsView` / `AuthenticatedCards`) — pattern to copy |
## Branding
The repo says "TCG Vault" everywhere except `components/Layout.js`, which renders "Deck Hearth" and "DH" logo. A naming convoy is open. Until resolved, **do not introduce a third name** in new copy.

View file

@ -0,0 +1,113 @@
---
name: add-api-route
description: >-
Add a new authenticated API route under pages/api/. Use when you need to
expose a new server endpoint to the client, scaffold an admin-only route, or
add a CRUD method to an existing resource. Walks through file placement,
auth, validation, DB access, and error handling for the tcg-vault stack.
---
# Add an API route
`pages/api/<path>.js` becomes `/api/<path>`. Dynamic segments use `[name]` folder/file naming.
## Step 1: Decide the path
| Pattern | Example | Notes |
| --- | --- | --- |
| Resource collection | `pages/api/decks.js``/api/decks` | GET list, POST create |
| Single resource | `pages/api/decks/[id].js``/api/decks/:id` | GET, PUT, DELETE |
| Sub-resource | `pages/api/decks/[id]/cards.js` | GET, POST |
| Action | `pages/api/cards/find-or-create.js` | POST, RPC-style |
## Step 2: Pick the auth pattern
| Use case | Wrapper |
| --- | --- |
| Generic logged-in user | `getUserFromRequest(req)` inline |
| Collection-scoped op | `withCollectionPermission('viewer'\|'editor'\|'owner')` |
| Admin-only | inline `if (user.role !== 'admin') return res.status(403)` |
## Step 3: Skeleton
```js
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../lib/permission-middleware';
export default async function handler(req, res) {
// 1. Method gate
if (!['GET', 'POST'].includes(req.method)) {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// 2. Auth
const user = await getUserFromRequest(req);
if (!user || !req.headers.authorization) {
// Guard against the known dev-fallback bug; require real Bearer token.
return res.status(401).json({ error: 'Authentication required' });
}
if (req.method === 'GET') {
const { rows } = await sql`
SELECT id, name FROM example_table WHERE user_id = ${user.userId}
`;
return res.status(200).json({ items: rows });
}
// 3. Validate body
const { name } = req.body || {};
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'name is required' });
}
// 4. Mutation
const { rows } = await sql`
INSERT INTO example_table (user_id, name)
VALUES (${user.userId}, ${name.trim()})
RETURNING id, name
`;
return res.status(201).json({ item: rows[0] });
} catch (err) {
console.error('[example handler]', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
```
## Step 4: Wire the client
Use `fetch` with the auth header pattern from existing pages:
```js
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/example', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name }),
});
```
## Step 5: Check
- [ ] Method gate is the first thing in the handler.
- [ ] Auth check requires both `getUserFromRequest` AND `req.headers.authorization` (until the middleware bug is fixed).
- [ ] All inputs validated.
- [ ] Tagged-template SQL only (no `db.query(...)`).
- [ ] try/catch wraps the whole body.
- [ ] If the route mutates a collection: call `logCollectionActivity`.
- [ ] Add a Convoy entry if the route is new functionality (vs. a bug fix).
## Anti-patterns
| Don't | Do |
| --- | --- |
| `db.query(\`SELECT … \${userInput}\`)` | `await sql\`SELECT … \${userInput}\`` |
| Forget the method gate | Always declare which methods are allowed |
| Return `res.json(err.message)` in catch | Generic message; log details server-side |
| `SELECT *` from `cards` | Project narrow columns |
| Add another `/api/test-*` endpoint | Use a real test runner once one's adopted |

View file

@ -0,0 +1,109 @@
---
name: add-page
description: >-
Add a new Next.js page under pages/. Use when you need a new route, a new
view for an existing resource, or an admin-only screen. Covers Layout
wiring, auth, theme tokens, and the public/authenticated split pattern.
---
# Add a page
`pages/<file>.js` becomes a route. Pages router conventions:
| File | Route |
| --- | --- |
| `pages/about.js` | `/about` |
| `pages/cards/[id].js` | `/cards/:id` (but use `pages/card/[id].js` per existing naming) |
| `pages/admin/index.js` | `/admin` |
## Step 1: Decide auth shape
| Mode | Template |
| --- | --- |
| Public-only | Render without `ProtectedRoute`; no auth check |
| Auth-required | Wrap top-level export with `<ProtectedRoute>` |
| Admin-only | Wrap with `<AdminProtected>` from `components/AdminProtected.js` |
| Public + auth-enhanced (e.g. `/cards`) | Inline split: render `<PublicView>` if not logged in, `<AuthedView>` if logged in. Copy the pattern from `pages/cards.js`. |
## Step 2: Skeleton
```js
import { useState, useEffect } from 'react';
import Layout from '../components/Layout';
import ProtectedRoute from '../components/ProtectedRoute';
import { useAuth } from '../lib/use-auth';
export default function MyPage() {
return (
<ProtectedRoute>
<MyPageInner />
</ProtectedRoute>
);
}
function MyPageInner() {
const { user, loading } = useAuth();
const [items, setItems] = useState([]);
const [fetching, setFetching] = useState(false);
useEffect(() => {
if (loading || !user) return;
const token = localStorage.getItem('auth_token');
setFetching(true);
fetch('/api/my-resource', {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setItems(data.items || []))
.catch((err) => console.error('fetch failed', err))
.finally(() => setFetching(false));
}, [user, loading]);
return (
<Layout user={user} showSearch={false}>
<div className="p-6" style={{ backgroundColor: 'var(--bg-primary)' }}>
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
My Page
</h1>
{fetching ? <p>Loading…</p> : items.map((i) => <div key={i.id}>{i.name}</div>)}
</div>
</Layout>
);
}
```
## Step 3: Theme tokens (not hex)
- Backgrounds → `var(--bg-primary)`, `var(--bg-secondary)`, `var(--bg-tertiary)`
- Text → `var(--text-primary)`, `var(--text-secondary)`
- Accents → `var(--accent-ember)`, `var(--accent-flame)`
- Borders → `var(--border)`
Use Tailwind for layout, spacing, sizing, hover/focus states. Use CSS vars (inline `style={{ ... }}`) for colors that need to switch with theme.
## Step 4: Always pass `user` to Layout
`<Layout user={user}>` — never let the default kick in (it's a hardcoded maintainer email; see `AGENTS.md` Gotcha #8).
## Step 5: Mobile
`components/Layout.js` already handles the mobile drawer + bottom nav. To add the page to nav, edit `NavigationContent` in `Layout.js`. Use existing icon names from the `getIcon` registry; add new ones to that registry before referencing.
## Step 6: Check
- [ ] Auth wrapper chosen (ProtectedRoute / AdminProtected / public).
- [ ] `useAuth()` from `lib/use-auth.js` (not the legacy `lib/auth-context.js`).
- [ ] `user` passed to Layout explicitly.
- [ ] Colors come from theme tokens, not hex.
- [ ] All interactive elements have `aria-label` or visible text.
- [ ] Mobile: confirm the page renders in the mobile drawer.
## Anti-patterns
| Don't | Do |
| --- | --- |
| Hardcode hex colors | Use CSS variables |
| Default `user = { … }` to a real email | Default to `null` |
| Pull from `lib/auth-context` for new code | Use `lib/use-auth` |
| Render Layout twice on the same page | Single `<Layout>` at the top |

43
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,43 @@
# CODEOWNERS — review routing for the agent pipeline.
# Replace @YOUR-GITHUB-HANDLE with the project owner's GitHub handle.
# Global default
* @YOUR-GITHUB-HANDLE
# High-risk: auth — every change here needs a maintainer review
pages/api/auth/** @YOUR-GITHUB-HANDLE
pages/api/auth-utils.js @YOUR-GITHUB-HANDLE
lib/permission-middleware.js @YOUR-GITHUB-HANDLE
lib/auth-context.js @YOUR-GITHUB-HANDLE
lib/admin-auth.js @YOUR-GITHUB-HANDLE
lib/use-auth.js @YOUR-GITHUB-HANDLE
components/ProtectedRoute.js @YOUR-GITHUB-HANDLE
components/AdminProtected.js @YOUR-GITHUB-HANDLE
# High-risk: admin operations + DB setup
pages/api/admin/** @YOUR-GITHUB-HANDLE
pages/api/setup-database.js @YOUR-GITHUB-HANDLE
scripts/setup-neon-db.js @YOUR-GITHUB-HANDLE
scripts/reset-db.js @YOUR-GITHUB-HANDLE
scripts/promote-user-to-admin.js @YOUR-GITHUB-HANDLE
scripts/demote-admin-to-user.js @YOUR-GITHUB-HANDLE
# Schema changes need extra eyes (until a real migration tool is adopted)
scripts/add-*.js @YOUR-GITHUB-HANDLE
scripts/fix-*.js @YOUR-GITHUB-HANDLE
docs/SCHEMA_MAP.md @YOUR-GITHUB-HANDLE
# Feature flags
lib/flags/** @YOUR-GITHUB-HANDLE
# CI / infra
.github/workflows/** @YOUR-GITHUB-HANDLE
next.config.js @YOUR-GITHUB-HANDLE
vercel.json @YOUR-GITHUB-HANDLE
# Agent context
.cursor/agents/** @YOUR-GITHUB-HANDLE
.cursor/rules/** @YOUR-GITHUB-HANDLE
.cursor/skills/** @YOUR-GITHUB-HANDLE
AGENTS.md @YOUR-GITHUB-HANDLE
.agent-context-manifest.yml @YOUR-GITHUB-HANDLE

44
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,44 @@
<!--
pipeline: convoy=<slug>, brief=<N>
skip: <comma-separated flags or empty>
Skip flags (Conductor sets these — do not edit by hand):
ia, ux, arch, test, review, visual, a11y, design, smoke, qa, docs, flag
Never skip: plan-approval, pr-merge, prod-promote
-->
## Summary
<!-- 2-3 bullets: what changed and why. User-facing language preferred. -->
## Convoy + Brief
- Convoy: `.convoys/<slug>.md`
- Brief: `.convoys/<slug>/brief-<N>-...md`
## Acceptance criteria
<!-- Copy from the brief; check off as you complete. -->
- [ ]
- [ ]
- [ ] No scope expansion (only files listed in the brief's `files:` were edited)
## Test plan
<!-- What was tested, how, and what wasn't tested with rationale. -->
## Pipeline gates
<!-- Filled in by CI / role-reviewer. Don't edit. -->
- [ ] CI: lint, types, build, unit tests
- [ ] Visual diff (if UI change)
- [ ] A11y audit (if UI change)
- [ ] Design-system audit (if UI change)
- [ ] Reviewer report
- [ ] Smoke on staging (after merge to develop)
## Notes for reviewer
<!-- Anything unusual, intentional trade-offs, or follow-ups. -->

View file

@ -0,0 +1,164 @@
# agent-context drift detection
#
# Weekly + on-demand check: does this repo's installed agent-pipeline
# artifacts match the latest upstream pipeline release?
#
# - Reads .agent-context-manifest.yml (committed at repo root)
# - Clones the pipeline repo at its latest tag
# - Compares each tracked artifact's installed_hash to the pipeline source hash
# - Compares manifest pipeline_version to pipeline version.txt
# - Opens (or updates) an issue titled "agent-context: N files behind v<X>"
# if drift is detected
#
# No auto-fix. The fix workflow is: a human runs `sync-agent-context` in
# Cursor and reviews per-file diffs.
name: agent-context-drift
on:
schedule:
# Mondays at 13:00 UTC. Adjust to taste.
- cron: "0 13 * * 1"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
drift:
runs-on: ubuntu-latest
steps:
- name: Checkout consumer repo
uses: actions/checkout@v4
- name: Read manifest
id: manifest
run: |
if [ ! -f .agent-context-manifest.yml ]; then
echo "::warning::No .agent-context-manifest.yml — agent-pipeline not installed or pre-v0.3.0. Skipping drift check."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
INSTALLED=$(grep -E '^pipeline_version:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/')
SOURCE=$(grep -E '^pipeline_source:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/')
echo "installed_version=$INSTALLED" >> "$GITHUB_OUTPUT"
echo "pipeline_source=$SOURCE" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Clone pipeline at latest tag
if: steps.manifest.outputs.skip != 'true'
id: pipeline
run: |
PIPELINE_URL="${{ steps.manifest.outputs.pipeline_source }}"
# Convert HTTPS URL → clone target. Already in HTTPS form.
mkdir -p /tmp/pipeline
git clone --depth 50 "$PIPELINE_URL" /tmp/pipeline
cd /tmp/pipeline
LATEST_TAG=$(git tag --sort=-v:refname | head -1)
if [ -z "$LATEST_TAG" ]; then
echo "::warning::Pipeline repo has no tags. Comparing against main."
LATEST_TAG="main"
fi
git checkout "$LATEST_TAG"
PIPELINE_VER=$(cat version.txt | tr -d '[:space:]')
echo "tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
echo "version=$PIPELINE_VER" >> "$GITHUB_OUTPUT"
- name: Compute drift
if: steps.manifest.outputs.skip != 'true'
id: drift
run: |
INSTALLED="${{ steps.manifest.outputs.installed_version }}"
UPSTREAM="${{ steps.pipeline.outputs.version }}"
BEHIND=0
CUSTOMIZED=0
CONFLICT=0
# Walk manifest artifacts. For each, compare installed_hash to local
# current hash, and pipeline source hash to installed_hash.
# YAML parsing in bash is intentionally minimal — relies on the
# bootstrap skill emitting a predictable shape.
python3 - <<'PY' >> drift-report.md
import hashlib, sys, yaml, os
def sha(path):
if not os.path.exists(path):
return None
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()
with open(".agent-context-manifest.yml") as f:
m = yaml.safe_load(f)
counts = {"behind": [], "customized": [], "conflict": [], "deleted": []}
for art in m.get("artifacts", []):
local_hash = sha(art["path"])
pipe_hash = sha(os.path.join("/tmp/pipeline", art["source"]))
if local_hash is None:
counts["deleted"].append(art["path"])
continue
local_matches = local_hash == art["installed_hash"]
pipe_changed = pipe_hash is not None and pipe_hash != art["installed_hash"]
if local_matches and pipe_changed:
counts["behind"].append(art["path"])
elif not local_matches and pipe_changed:
counts["conflict"].append(art["path"])
elif not local_matches:
counts["customized"].append(art["path"])
print("# agent-context drift report")
print(f"\nInstalled: `{m.get('pipeline_version')}` · Upstream: `${{ steps.pipeline.outputs.version }}` (`${{ steps.pipeline.outputs.tag }}`)")
for kind in ("behind", "conflict", "customized", "deleted"):
files = counts[kind]
if files:
print(f"\n## {kind} ({len(files)})")
for f in files:
print(f"- `{f}`")
PY
BEHIND=$(grep -c '^## behind' drift-report.md || echo 0)
CONFLICT=$(grep -c '^## conflict' drift-report.md || echo 0)
NEED_ISSUE="false"
if [ "$INSTALLED" != "$UPSTREAM" ] || [ "$BEHIND" -gt 0 ] || [ "$CONFLICT" -gt 0 ]; then
NEED_ISSUE="true"
fi
echo "need_issue=$NEED_ISSUE" >> "$GITHUB_OUTPUT"
echo "upstream_version=$UPSTREAM" >> "$GITHUB_OUTPUT"
- name: Open / update drift issue
if: steps.manifest.outputs.skip != 'true' && steps.drift.outputs.need_issue == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('drift-report.md', 'utf8') +
'\n\n---\n\n_To resolve: open this repo in Cursor and ask **"Sync agent context for this repo"**. The sync skill walks the diff per file._';
const title = `agent-context: behind ${{ steps.drift.outputs.upstream_version }}`;
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'agent-context-drift',
});
const found = existing.data.find(i => i.title.startsWith('agent-context: behind'));
if (found) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: found.number,
title,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['agent-context-drift'],
});
}

103
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,103 @@
name: CI
# Vercel variant: Vercel builds Preview deployments on every push and gates the
# PR via the Vercel GitHub integration check. Running `npm run build` here too
# would duplicate Vercel's work for ~3-5 minutes per PR with no added signal.
#
# What this CI covers (and Vercel does not):
# - Lint (cheap belt-and-suspenders)
# - Schema-map drift check (docs/SCHEMA_MAP.md updated when scripts/add-*.js changes)
#
# NOTE: tcg-vault has no test runner installed yet. Re-enable the `test:` job
# below once vitest (or equivalent) is adopted AND a `test:run` script exists
# in package.json. See .convoys/ for the testing convoy.
#
# NOTE: tcg-vault is JavaScript (not TypeScript). No `npx tsc --noEmit` step.
# Re-enable a type-check job if migrating to TypeScript.
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
# TODO(fix-lint-baseline): drop the `|| true` wrapper once .convoys/fix-lint-baseline
# lands. The codebase has ~100 pre-existing ESLint errors (conditional React
# hooks, unescaped entities, etc.). For now lint runs and posts output as a
# warning annotation so the PR check stays green while the debt is visible.
- name: Lint (non-blocking until fix-lint-baseline)
run: |
set +e
npm run lint --if-present
status=$?
if [ "$status" -ne 0 ]; then
echo "::warning title=Lint errors (non-blocking)::ESLint reported errors above. Tracked in .convoys/ship-readiness.md as P1 #11.5 (fix-lint-baseline). Remove the wrapper in .github/workflows/ci.yml after baseline is fixed."
fi
exit 0
schema-map-fresh:
name: Schema map up to date
runs-on: ubuntu-latest
# Only run when migration scripts or the schema map itself changed.
# If neither changed, nothing to verify.
if: |
contains(github.event.pull_request.changed_files, 'scripts/add-') ||
contains(github.event.pull_request.changed_files, 'scripts/fix-') ||
contains(github.event.pull_request.changed_files, 'scripts/setup-neon-db.js') ||
contains(github.event.pull_request.changed_files, 'docs/SCHEMA_MAP.md')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Verify schema map updated alongside migration scripts
run: |
MIGRATION_CHANGED=false
MAP_CHANGED=false
if git diff --name-only HEAD~1 | grep -qE '^scripts/(add-|fix-|setup-neon-db\.js)'; then
MIGRATION_CHANGED=true
fi
if git diff --name-only HEAD~1 | grep -q '^docs/SCHEMA_MAP\.md$'; then
MAP_CHANGED=true
fi
if [ "$MIGRATION_CHANGED" = "true" ] && [ "$MAP_CHANGED" = "false" ]; then
echo "::error::A migration script changed but docs/SCHEMA_MAP.md was not updated."
echo "Update docs/SCHEMA_MAP.md to reflect the schema change, then re-push."
exit 1
fi
echo "OK: schema map and migration scripts are in sync."
# test:
# Disabled until a test runner is adopted. Re-enable as:
#
# test:
# name: Unit + integration tests
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: ${{ env.NODE_VERSION }}
# cache: npm
# - run: npm ci
# - run: npm run test:run
# env:
# JWT_SECRET: ci-secret-only-for-tests
# POSTGRES_URL: postgres://ci:ci@localhost:5432/ci

97
.github/workflows/pr-health-rollup.yml vendored Normal file
View file

@ -0,0 +1,97 @@
name: PR Health rollup
# Rolls up CI gates AND the Vercel deployment status posted by the Vercel
# GitHub integration. Build status comes from Vercel, not from our own CI.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
workflow_run:
workflows: [CI, Preview smoke, Visual diff]
types: [completed]
permissions:
pull-requests: write
issues: write
checks: read
deployments: read
jobs:
rollup:
name: Aggregate gate status
runs-on: ubuntu-latest
steps:
- name: Compute status + post sticky comment
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pr_number = context.payload.pull_request?.number
?? context.payload.workflow_run?.pull_requests?.[0]?.number;
if (!pr_number) {
core.info('No PR context — skipping rollup.');
return;
}
const pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr_number })).data;
const sha = pr.head.sha;
const checks = (await github.rest.checks.listForRef({ owner, repo, ref: sha, per_page: 100 })).data.check_runs;
const find = (name) => checks.find(c => c.name === name);
const vercelCheck = checks.find(c => /^vercel/i.test(c.name));
const skip = (flag) =>
new RegExp(`pipeline:.*skip[^\\n]*\\b${flag}\\b`).test(pr.body || '');
const row = (label, run, opt = false) => {
if (!run) return `| ${label} | ${opt ? '⏭ skipped or pending' : '⏳ pending'} |`;
if (run.status !== 'completed') return `| ${label} | ⏳ in progress |`;
const ok = run.conclusion === 'success';
return `| ${label} | ${ok ? '✅ pass' : '❌ ' + run.conclusion} |`;
};
const rows = [
row('Vercel build (Preview)', vercelCheck),
row('CI: Lint', find('Lint')),
row('CI: Schema map fresh', find('Schema map up to date'), true),
skip('smoke') ? '| Preview smoke | ⏭ skipped (pipeline directive) |' : row('Preview smoke', find('Playwright smoke'), true),
skip('visual') ? '| Visual diff | ⏭ skipped (pipeline directive) |' : row('Visual diff', find('Screenshot diff'), true),
];
const reviewer_comment = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data.find(c => c.body?.startsWith('## Reviewer Report'));
const a11y_comment = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data.find(c => c.body?.startsWith('## A11y Audit'));
const ds_comment = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data.find(c => c.body?.startsWith('## Design System Audit'));
const role_row = (label, c, skipped) =>
skipped ? `| ${label} | ⏭ skipped |` : c ? `| ${label} | ✅ posted |` : `| ${label} | ⏳ pending |`;
const role_rows = [
role_row('Reviewer report', reviewer_comment, skip('review')),
role_row('A11y audit', a11y_comment, skip('a11y')),
role_row('Design system audit', ds_comment, skip('design')),
];
const marker = '<!-- pipeline-rollup -->';
const body = `${marker}\n## Pipeline Health\n\n### Build + CI gates\n\n| Gate | Status |\n| --- | --- |\n${rows.join('\n')}\n\n_Build runs on Vercel; this CI runs lint and schema-map drift only (no duplicate build)._\n\n### Role reports\n\n| Role | Status |\n| --- | --- |\n${role_rows.join('\n')}\n\nSee individual comments above for details. This rollup updates automatically.`;
const comments = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data;
const existing = comments.find(c => c.body?.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body });
}

76
.github/workflows/preview-smoke.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: Preview smoke
# Waits for Vercel's per-PR Preview deployment to be ready, then runs
# Playwright smoke against its URL.
#
# Vercel's GitHub integration auto-deploys every push and posts a Deployment
# to the GitHub API once ready. We wait on that Deployment so we always hit
# the canonical preview URL Vercel just published.
#
# REQUIRES: @playwright/test installed. Until then, this workflow will fail
# on `npx playwright install`. See .convoys/ for the testing convoy.
on:
pull_request:
branches: [main]
types: [labeled, opened, synchronize, reopened]
concurrency:
group: preview-smoke-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
gate:
name: Should run?
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Decide
id: check
run: |
if echo "${{ github.event.pull_request.body }}" | grep -qE 'pipeline:.*skip.*\bsmoke\b'; then
echo "should_run=false" >> $GITHUB_OUTPUT
echo "::notice::Smoke skipped via pipeline directive"
else
echo "should_run=true" >> $GITHUB_OUTPUT
fi
smoke:
name: Playwright smoke
needs: gate
if: needs.gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Wait for Vercel Preview deployment
id: vercel
uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 600
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run smoke tests
run: npx playwright test --project=smoke
env:
BASE_URL: ${{ steps.vercel.outputs.url }}
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

88
.github/workflows/visual-diff.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: Visual diff
# Same as preview-smoke — waits for Vercel's Preview deployment, then captures
# Playwright screenshots against it. UI-paths-only trigger to keep cost down.
# Paths are tcg-vault-specific (pages router, JS).
on:
pull_request:
branches: [main]
paths:
- 'pages/**'
- 'components/**'
- 'styles/**'
- 'tailwind.config.js'
- 'postcss.config.js'
concurrency:
group: visual-diff-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
gate:
name: Should run?
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: check
run: |
if echo "${{ github.event.pull_request.body }}" | grep -qE 'pipeline:.*skip.*\bvisual\b'; then
echo "should_run=false" >> $GITHUB_OUTPUT
echo "::notice::Visual diff skipped via pipeline directive"
else
echo "should_run=true" >> $GITHUB_OUTPUT
fi
visual:
name: Screenshot diff
needs: gate
if: needs.gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Wait for Vercel Preview deployment
id: vercel
uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 600
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Capture screenshots (PR)
run: npx playwright test --project=visual --update-snapshots=none
env:
BASE_URL: ${{ steps.vercel.outputs.url }}
continue-on-error: true
- name: Upload screenshots + diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-diff
path: |
tests/visual/__screenshots__/
test-results/
retention-days: 7
- name: Comment on PR with diff link
if: always()
uses: actions/github-script@v7
with:
script: |
const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Visual Diff\n\nScreenshots and diffs uploaded as artifacts: [view run](${run})\n\nIf intentional changes: update snapshots locally with \`npx playwright test --project=visual --update-snapshots\` and commit.`
});

7
.gitignore vendored
View file

@ -29,3 +29,10 @@ __pycache__/
/out/ /out/
.env*.local .env*.local
# Agent pipeline self-analytics (per-developer; opt-out by default).
# Remove this line if you want team-shared convoy metrics committed.
.convoys/.metrics.jsonl
# Local code-knowledge-graph (per-developer; user-code-review-graph MCP)
.code-review-graph/

72
AGENTS.md Normal file
View file

@ -0,0 +1,72 @@
# AGENTS.md — AI collaboration (tcg-vault)
Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.
> Branding note: the repo, README, and seed data say "TCG Vault" and `admin@tcgvault.com`, but the Layout component renders "Deck Hearth". Pick one before launch — see `.convoys/` for tracking.
## 1. Project overview
A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database.
- **Framework:** Next.js 15 (Pages router) + React 18, JavaScript (not TypeScript)
- **Data:** Neon Postgres, accessed two different ways — `@neondatabase/serverless` (`lib/database.js`) AND raw `@vercel/postgres` (`pages/api/**`). Pick ONE; see Gotcha #1.
- **Auth:** Custom JWT (jsonwebtoken + bcryptjs), token stored in `localStorage`, sent as `Authorization: Bearer …`. No NextAuth.
- **UI:** Tailwind CSS + custom CSS variables for theming (light/dark via `lib/theme-context.js`)
- **Hosting:** Vercel (`vercel.json`, `.vercel/` present)
## 2. Architecture quick reference
| Area | Path | Notes |
| --- | --- | --- |
| Pages router views | `pages/*.js` | Public + auth views; uses `components/Layout.js` |
| API routes | `pages/api/**/*.js` | Express-style `handler(req, res)`. **30+ handlers depend on `lib/permission-middleware.js::getUserFromRequest`** |
| Shared UI | `components/*.js` | `Layout`, `CardItem`, `CameraScanner`, modal family |
| Auth + DB libs | `lib/*.js` | `auth-context`, `admin-auth`, `use-auth` (three parallel auth surfaces), `database`, `permission-middleware` |
| Migration scripts | `scripts/*.js` | 27+ one-off "add column" / "seed" scripts. No formal migration tool |
| Card-import jobs | `pages/api/cards/import-*.js`, `scripts/import-*.js` | Scryfall / Lorcana / Pokémon TCG APIs |
| Database schema | `scripts/setup-neon-db.js` | Bootstrap SQL DDL — the source of truth until a real migration tool lands |
| Schema map | `docs/SCHEMA_MAP.md` | Hand-curated; regenerate after schema changes |
Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 5602 edges). Ask: *"what calls `getUserFromRequest`?"* before refactoring auth.
## 3. Key conventions
- **Auth (server):** `import { getUserFromRequest } from '../../lib/permission-middleware'` → returns `{ userId, email, role }` or `null`. **IMPORTANT: the current implementation returns a hardcoded admin user when no Bearer token is present — treat that as a known prod bug, do NOT copy the pattern.**
- **Auth (client):** `import { useAuth } from '../lib/use-auth'`. Avoid `lib/auth-context.js` and `lib/admin-auth.js` for new code — they are legacy parallel implementations.
- **Auth helper (JWT only):** `import { ... } from '../../lib/api/auth-utils'` (`generateToken`, `verifyToken`, `hashPassword`, `verifyPassword`).
- **Permission gate for collection routes:** wrap handlers with `withCollectionPermission('viewer' | 'editor' | 'owner')` from `lib/permission-middleware.js`.
- **DB access:** Use **tagged-template** style — `import { sql } from '@vercel/postgres'`. Avoid the legacy `lib/database.js` `db.query(string, params)` API; its parameter interpolation uses `sql.unsafe` and is a SQL-injection vector.
- **Activity logging:** `logCollectionActivity(collectionId, userId, action, details)` — call it from any handler that mutates a collection.
- **File names:** `kebab-case.js` for libs/scripts; `PascalCase.js` for React components.
- **Imports:** No path aliases configured; use relative imports.
- **Slugs:** `lib/slug-utils.js::generateUniqueSlug` for any user-facing identifier (collections, decks).
- **CSS theme tokens:** Components read `var(--bg-primary)`, `var(--text-primary)`, `var(--accent-ember)`, etc. — defined in `styles/`. Don't hardcode hex colors.
## 4. Common gotchas
- **#1 — Two SQL clients live in parallel.** `@neondatabase/serverless` (used by `lib/database.js`) and `@vercel/postgres` (used by most `pages/api/**` handlers). New code: prefer `@vercel/postgres` tagged templates. Migration to a single client is tracked in `.convoys/`.
- **#2`getUserFromRequest` has a dev fallback shipped to prod.** When no Bearer token is present it returns user 1 as admin. This is a critical security issue, NOT a feature. Don't rely on it; treat unauthenticated requests as 401.
- **#3 — JWT_SECRET default is hardcoded across 7 files.** If `process.env.JWT_SECRET` is unset, tokens are signed with `'your-secret-key-change-in-production'`. The Vercel project MUST set `JWT_SECRET`; CI/staging too.
- **#4 — Default admin credentials are in the seed.** `admin@tcgvault.com` / `admin123` from `scripts/setup-neon-db.js`. Change the password immediately after running setup.
- **#5`pages/api/setup-database.js` is a public endpoint.** Anyone hitting it triggers DB DDL. Either delete or gate behind admin auth before public launch.
- **#6 — Migrations are bare scripts.** `scripts/add-*.js` and `scripts/fix-*.js` are run-once jobs with no idempotency tracking. Adopt `node-pg-migrate`, `kysely`, or `drizzle-kit` before more schema changes.
- **#7 — Dual `is_public` semantics.** Collections and decks both have `is_public` columns; check which controls discovery vs. anonymous read in the relevant route.
- **#8 — Layout has hardcoded default user.** `Layout({ user = { email: 'me@randallstillwell.com', role: 'user' } })`. Anything rendering Layout without passing `user` will impersonate the maintainer. Pass `user` explicitly from every page.
## 5. Running locally
- **Runtime:** Node 20 (Vercel default).
- **Setup:** `npm install`, copy `.env.local` template (POSTGRES_URL + JWT_SECRET + RESEND_API_KEY + BLOB_READ_WRITE_TOKEN), then `npm run setup-db` once.
- **Dev server:** `npm run dev` → http://localhost:3000.
## 6. Testing
- **Runner:** None yet. Adding `vitest` + `@playwright/test` is in `.convoys/`. Until then: manual smoke per `TESTING_GUIDE.md`.
## 7. Deployment
- **Vercel** auto-deploys `main` and creates Preview deployments for every PR. `vercel.json` and `.vercel/` are committed. CI in `.github/workflows/` runs lint + types (no duplicate build — Vercel handles it).
## 8. Code graph
A local code-knowledge-graph MCP server (`user-code-review-graph`) is set up for this repo. Ask "what calls X?" or "show me the flow from /api/auth/login" instead of grepping. See [`docs/agent-context/README.md`](docs/agent-context/README.md).

181
docs/SCHEMA_MAP.md Normal file
View file

@ -0,0 +1,181 @@
# SCHEMA_MAP.md
> Hand-curated reference for the Neon Postgres schema. The actual schema is the union of `scripts/setup-neon-db.js` (initial DDL) plus every `scripts/add-*.js` / `scripts/fix-*.js` that has been run. Until a real migration tool is adopted, this file is the source of truth for agents and humans.
>
> **Last reviewed:** 2026-05-22 against `scripts/setup-neon-db.js` + every `scripts/add-*.js` and `scripts/fix-*.js` in repo HEAD.
## Quick model groups
| Group | Tables | Purpose |
| --- | --- | --- |
| **Identity** | `users`, `user_settings`, `user_avatars` | Accounts, profile, preferences |
| **Catalog** | `cards` | Master card list across MTG / Pokémon / Lorcana |
| **Ownership** | `user_cards`, `user_favorites` | What a user owns / has favorited |
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing |
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
| **Invitations** | `invitations` (referenced; verify) | Pending share requests |
## Tables
### users
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `email` | `VARCHAR(255) UNIQUE NOT NULL` | Lowercase before query/insert (no CI collation set) |
| `password` | `VARCHAR(255) NOT NULL` | bcrypt hash, cost 12 |
| `role` | `VARCHAR(50)` default `'user'` | `'user' \| 'admin'` |
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
| `first_name`, `last_name` | `VARCHAR(255)` | From `add-user-profile-columns.js` |
| `username` | `VARCHAR(255) UNIQUE` | |
| `profile_image_url`, `avatar_url` | `TEXT` | Two redundant columns; verify which is canonical |
| `bio` | `TEXT` | |
| `favorite_games` | `JSONB` default `'["MTG"]'` | Per-user game preference array |
| `collection_visibility` | `VARCHAR(20)` default `'private'` | |
| `preferred_currency` | `VARCHAR(3)` default `'USD'` | |
| `cards_per_page` | `INTEGER` default `50` | |
| `default_view` | `VARCHAR(10)` default `'grid'` | |
| `notifications_email` | `BOOLEAN` default `true` | |
| `notifications_marketing` | `BOOLEAN` default `false` | |
| `two_factor_enabled` | `BOOLEAN` default `false` | Not implemented yet |
| `theme` | `VARCHAR(10)` default `'system'` | `'light' \| 'dark' \| 'system'` |
| `language` | `VARCHAR(5)` default `'en'` | |
| `is_pending` | `BOOLEAN` default `false` | Set by invitation flow before signup completes |
### cards
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `name` | `VARCHAR(255) NOT NULL` | |
| `set_name`, `set_code`, `card_number` | `VARCHAR` | |
| `rarity` | `VARCHAR(50)` | |
| `game` | `VARCHAR(50) NOT NULL` | `'mtg' \| 'pokemon' \| 'lorcana'` |
| `mana_cost` | `VARCHAR(50)` | MTG only |
| `cmc` | `INTEGER` | MTG only |
| `card_type` | `VARCHAR(255)` | |
| `colors` | `JSONB` | MTG color array |
| `oracle_text` | `TEXT` | LARGE — never `SELECT *` |
| `power`, `toughness` | `VARCHAR(10)` | MTG creatures |
| `image_url`, `stock_image_url` | `TEXT` | |
| `current_price`, `market_price` | `DECIMAL(10,2)` | |
| `scryfall_id` | `VARCHAR(255) UNIQUE` | Use for dedupe on MTG import |
| `verified` | `BOOLEAN` default `false` | Admin-edited cards |
| `quantity` | `INTEGER` default `0` | **Unused; consider dropping — quantity lives in `user_cards`** |
| `favorited` | `BOOLEAN` default `false` | **Unused; favorites live in `user_favorites`** |
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
### user_cards
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `user_id` | `INTEGER FK users(id) ON DELETE CASCADE` | |
| `card_id` | `INTEGER FK cards(id) ON DELETE CASCADE` | |
| `quantity` | `INTEGER` default `1` | |
| `condition` | `VARCHAR(50)` default `'NM'` | NM / LP / MP / HP / DMG |
| `is_foil` | `BOOLEAN` default `false` | |
| `notes` | `TEXT` | |
| | | **UNIQUE(user_id, card_id, is_foil)** |
### user_favorites
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `user_id`, `card_id` | FKs cascade | |
| `created_at` | `TIMESTAMP` | |
| | | **UNIQUE(user_id, card_id)** (verify constraint exists) |
### collections
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `user_id` | `INTEGER FK users(id) ON DELETE CASCADE` | |
| `name` | `VARCHAR(255) NOT NULL` | |
| `description` | `TEXT` | |
| `is_public` | `BOOLEAN` default `false` | Used by `withCollectionPermission('viewer')` |
| `slug` | `VARCHAR(100) UNIQUE` | Format constraint: lowercase kebab, ≤50 chars |
| `image` | `TEXT` | Cover image |
| `visibility` | `VARCHAR(20)` default `'private'` | **Coexists with `is_public`; verify single source of truth** |
| `tcg` | `VARCHAR(50)` default `'MTG'` | |
| `tags` | `TEXT` | Comma-separated; consider migrating to JSONB array |
| `is_system_collection` | `BOOLEAN` default `false` | E.g. "All My Cards" auto-collection |
| `created_at`, `updated_at` | `TIMESTAMP` | |
### collection_cards
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `collection_id`, `card_id` | FKs cascade | |
| `quantity` | `INTEGER` default `1` | |
| | | **UNIQUE(collection_id, card_id)** |
### collection_permissions
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `collection_id`, `user_id` | FKs cascade | |
| `role` | `VARCHAR` | `'viewer' \| 'editor' \| 'owner'` |
| `status` | `VARCHAR` | `'pending' \| 'active' \| 'declined'` — used by invite flow |
| `created_at` | `TIMESTAMP` | |
### collection_activity
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `collection_id`, `user_id` | FKs | |
| `action` | `VARCHAR` | e.g. `'card_added'`, `'permission_granted'` |
| `details` | `JSONB` | |
| `created_at` | `TIMESTAMP` | |
### decks
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `user_id` | FK | |
| `name`, `description` | text | |
| `game` | `VARCHAR(50)` | |
| `is_public` | `BOOLEAN` default `false` | |
| `created_at`, `updated_at` | `TIMESTAMP` | |
### deck_cards
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `deck_id`, `card_id` | FKs cascade | |
| `quantity` | `INTEGER` default `1` | |
| | | **UNIQUE(deck_id, card_id)** |
### user_settings (split from users.*; verify which is canonical)
Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled.
### user_avatars
Tracks uploaded avatar history. Older avatars are typically deleted from blob storage; verify the cleanup job runs.
## Known schema smells
1. **Two `users` avatar columns**`profile_image_url` and `avatar_url`. Pick one.
2. **Two collection-visibility flags**`collections.is_public` (BOOLEAN) and `collections.visibility` (VARCHAR). Pick one.
3. **`cards.quantity` + `cards.favorited`** — these belong on `user_cards` / `user_favorites`, not the catalog. Drop them.
4. **`user_settings``users.*`** — split-brain. Reconcile.
5. **No formal constraints on enums**`role`, `condition`, `theme`, `language`, `game`, `visibility` are all `VARCHAR`. Consider CHECK constraints or proper ENUMs.
6. **`collections.tags` is `TEXT`** — should be `JSONB` or a join table.
## Regeneration
Until a migration tool lands:
```bash
rg "ALTER TABLE|CREATE TABLE|ADD COLUMN" scripts/
```
…then update this file by hand. A `npm run schema:map` script regenerated from the migration history is in `.convoys/`.

View file

@ -0,0 +1,78 @@
# Agent Context System
A three-layer system that gives AI coding agents fast, accurate orientation in this repo so they can start coding immediately instead of grepping a thousand files.
## The three layers
| Layer | Location | What it does | Token cost |
| --- | --- | --- | --- |
| **Intent** | [`AGENTS.md`](../../AGENTS.md) | High-level architecture, conventions, gotchas — always loaded | Always on |
| **Per-context guidance** | [`.cursor/rules/*.mdc`](../../.cursor/rules) | Glob-scoped rules (e.g. only loaded when editing matching files) | Loaded only when matching files are open |
| **On-demand recipes** | [`.cursor/skills/*`](../../.cursor/skills) | Step-by-step skills the agent reads when its description matches | Loaded only when invoked |
| **Schema map** | [`docs/SCHEMA_MAP.md`](../SCHEMA_MAP.md) | Hand-curated DB table reference for token-efficient agent queries | Loaded only when read |
## Plus a fourth: the structural brain
This repo also indexes itself with the [`user-code-review-graph`](https://github.com/varutasu/code-review-graph) MCP server. It runs locally per-developer and parses the codebase with Tree-sitter into a queryable graph of files, functions, classes, calls, and imports.
| You can ask… | …and get |
| --- | --- |
| "What calls `getUserFromRequest`?" | A list of every handler that depends on the auth fallback bug |
| "Where is `withCollectionPermission` used?" | Every collection-scoped route |
| "Show me the flow from `/api/auth/login`" | Call graph with imports and DB writes |
| "Find functions over 100 lines" | Refactor targets |
| "Show me bridge nodes between communities" | High-coupling functions to test carefully |
To use it in this repo:
1. Install + start the MCP server (`brew install pipx && pipx install code-review-graph` or per the project's README).
2. Open this repo in Cursor.
3. The MCP picks up the repo root automatically.
4. Ask the agent natural questions — the graph is the data source.
The graph is per-developer; no `.code-review-graph/` directory ever gets committed (it's already in `.gitignore`).
## Quickstart for a new task
1. **Open the file you'll edit.** Cursor automatically loads `AGENTS.md` and any `.cursor/rules/*.mdc` whose `globs:` match.
2. **Describe the task.** The agent has the conventions in scope; it does not need to grep for them.
3. **Drafting.** Agent proposes the change against the relevant rule's conventions.
4. **Verify.** `npm run lint` (no test runner yet — adding `vitest` is in `.convoys/`).
## How to extend
| You want to... | Do this |
| --- | --- |
| Add a new convention scoped to a folder | Create `.cursor/rules/<topic>.mdc` with frontmatter `description` + `globs:` |
| Add a step-by-step recipe agents can invoke | Create `.cursor/skills/<name>/SKILL.md` with frontmatter `description` |
| Mark a path as "do not touch" | Add it to [`.cursor/rules/no-go-zones.mdc`](../../.cursor/rules/no-go-zones.mdc) |
| Regenerate the schema map | Re-read `scripts/setup-neon-db.js` + any new `scripts/add-*.js`, then update `docs/SCHEMA_MAP.md` |
| Refresh the code graph | Ask Cursor to call the `build_or_update_graph_tool` MCP tool |
Always-on rules cost from a shared instruction budget — keep them tight. The "would removing this line cause a mistake the agent wouldn't otherwise make?" test is the gate.
## Subagent pipeline (L2 + L3)
In addition to L1 context, this repo has:
- **L2 — 9 subagent roles** in [`.cursor/agents/`](../../.cursor/agents/) covering the idea → IA → UX → architecture → implementation → review → docs flow.
- **L3 — pipeline scaffolding** in `.github/workflows/`, `.github/PULL_REQUEST_TEMPLATE.md`, `.github/CODEOWNERS`, [`.convoys/`](../../.convoys/), `lib/flags/`, and `scripts/log-convoy-event.sh`.
See [`.convoys/README.md`](../../.convoys/README.md) for how convoys work and how to start one. The full role reference is in the [agent-pipeline repo](https://github.com/varutasu/agent-pipeline).
## Multitask + worktrees (Cursor 3.2+)
After role-implementer ships a PR draft:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
All three read the same diff and emit independent comments. Use group id `audit-<convoy>-<pr>` so analytics can compute wall-clock savings.
For parallel implementer briefs (when `slice_dependencies` allows it), use Cursor's native Agents Window worktrees. Full playbook: [agent-pipeline docs/multitask-playbook.md](https://github.com/varutasu/agent-pipeline/blob/main/docs/multitask-playbook.md).
## What's intentionally NOT here
- No commits of `.cursor/mcp.json` to this repo (any MCP install is personal-only, in `~/.cursor/mcp.json`).
- No measurement protocol yet — the colab repo's -53% token result is documented upstream; we'll measure tcg-vault's number after a few convoys.

16
eslint.config.mjs Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
const eslintConfig = defineConfig([
...nextVitals,
globalIgnores([
'.next/**',
'node_modules/**',
'out/**',
'build/**',
'next-env.d.ts',
'scripts/migrations/**',
]),
]);
export default eslintConfig;

71
lib/flags/index.js Normal file
View file

@ -0,0 +1,71 @@
/**
* Feature flag wrapper. Lightweight, dependency-free, env-var driven.
*
* Usage:
* import { isEnabled } from '../../lib/flags';
* if (isEnabled('bookmark_count_badge', { userId: user?.userId })) {
* // ...
* }
*
* Flag values resolve from env vars: FLAG_<UPPER_SNAKE_NAME>=on|off|<percent>|<comma list of user ids>
* Examples:
* FLAG_BOOKMARK_COUNT_BADGE=on # everyone
* FLAG_BOOKMARK_COUNT_BADGE=off # nobody
* FLAG_BOOKMARK_COUNT_BADGE=10 # 10% of users (deterministic per userId)
* FLAG_BOOKMARK_COUNT_BADGE=u1,u2,u3 # specific user ids
*
* For a richer flag system (LaunchDarkly, Statsig, Unleash) replace the
* resolver below with an SDK call. The public API (isEnabled) stays the same.
*
* Pipeline integration: convoys with `skip: flag` in their frontmatter ship
* without flag-gating. Convoys without `skip: flag` MUST gate the new code
* behind a flag and document the rollout plan in the convoy file.
*/
const KNOWN_FLAGS = new Set([
// Add flags here as they're created. Helps catch typos.
// 'bookmark_count_badge',
]);
function envName(flag) {
return `FLAG_${flag.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
}
function hashUserId(userId, salt) {
let h = 0;
const s = `${salt}::${userId}`;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) % 100;
}
export function isEnabled(flag, ctx = {}) {
if (process.env.NODE_ENV !== 'test' && !KNOWN_FLAGS.has(flag)) {
if (typeof console !== 'undefined') {
console.warn(`[flags] unknown flag '${flag}'. Add it to KNOWN_FLAGS in lib/flags/index.js.`);
}
}
const raw = (ctx.envValue ?? process.env[envName(flag)] ?? 'off').trim().toLowerCase();
if (raw === 'on' || raw === 'true' || raw === '1') return true;
if (raw === 'off' || raw === 'false' || raw === '0' || raw === '') return false;
const pct = Number(raw);
if (!Number.isNaN(pct) && pct >= 0 && pct <= 100) {
if (!ctx.userId) return false;
return hashUserId(String(ctx.userId), flag) < pct;
}
if (raw.includes(',') || raw.length > 0) {
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
return Boolean(ctx.userId && ids.includes(String(ctx.userId)));
}
return false;
}
export function listKnownFlags() {
return [...KNOWN_FLAGS].sort();
}

View file

@ -1,8 +1,12 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
images: { images: {
domains: ['api.scryfall.com', 'images.pokemontcg.io', 'lorcana-api.com'], remotePatterns: [
{ protocol: 'https', hostname: 'api.scryfall.com' },
{ protocol: 'https', hostname: 'images.pokemontcg.io' },
{ protocol: 'https', hostname: 'lorcana-api.com' },
],
}, },
}; };
export default nextConfig; export default nextConfig;

1721
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "eslint .",
"setup-db": "node scripts/setup-neon-db.js", "setup-db": "node scripts/setup-neon-db.js",
"import-popular": "node scripts/import-popular-sets.js", "import-popular": "node scripts/import-popular-sets.js",
"import-all": "node scripts/bulk-import-all.js" "import-all": "node scripts/bulk-import-all.js"
@ -19,7 +19,7 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"next": "^15.4.2", "next": "^16.2.6",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -27,9 +27,10 @@
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^8", "eslint": "^9.39.4",
"eslint-config-next": "15.4.2", "eslint-config-next": "^16.2.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17",
"typescript": "^5.9.3"
} }
} }

89
scripts/log-convoy-event.sh Executable file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# log-convoy-event.sh — append one convoy event to .convoys/.metrics.jsonl.
#
# Used by L2 roles to emit lightweight metrics for self-analytics.
# Schema: github.com/varutasu/agent-pipeline/analytics/schemas/convoy-event.json
#
# Usage:
# bash scripts/log-convoy-event.sh role=role-conductor convoy=bookmark-badge \
# classification=feature 'skip_flags=visual,smoke' duration_s=42
#
# All args are key=value. Required: role, convoy.
# Optional: brief, classification, skip_flags (comma-separated), duration_s,
# stack_class, outcome, multitask_group.
#
# multitask_group: cohort id when this role ran as part of a Cursor 3.2
# /multitask fan-out (e.g. 'audit-bookmark-badge-PR123'). Events sharing
# this id should be aggregated with max(duration_s), not sum, for wall-clock.
# See docs/multitask-playbook.md.
#
# Privacy: this file is gitignored by default; events contain only metadata,
# no code or prompts. To opt-in to commit, remove `.convoys/.metrics.jsonl`
# from your `.gitignore`.
#
# Atomicity: concurrent invocations append safely because each python3
# subprocess writes one short JSON line via O_APPEND. POSIX guarantees
# writes <= PIPE_BUF are atomic on regular files opened with O_APPEND.
# Typical line size is 200-400 bytes; PIPE_BUF is 4096 on Linux and
# 512+ on macOS. Larger custom fields could break this — keep
# multitask_group <= 64 chars (matches the JSON schema).
#
# Portable across macOS bash 3.2 and Linux bash 4+; uses python3 (always
# present on macOS + most Linux) for safe JSON encoding.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
REPO_NAME="$(basename "$REPO_ROOT")"
METRICS_FILE="$REPO_ROOT/.convoys/.metrics.jsonl"
mkdir -p "$REPO_ROOT/.convoys"
# Pull values out of args without using associative arrays (bash 3.2 compat)
ROLE=""; CONVOY=""; BRIEF=""; CLASSIFICATION=""
SKIP_FLAGS=""; DURATION_S=""; STACK_CLASS=""; OUTCOME=""; MULTITASK_GROUP=""
for arg in "$@"; do
k="${arg%%=*}"
v="${arg#*=}"
case "$k" in
role) ROLE="$v" ;;
convoy) CONVOY="$v" ;;
brief) BRIEF="$v" ;;
classification) CLASSIFICATION="$v" ;;
skip_flags) SKIP_FLAGS="$v" ;;
duration_s) DURATION_S="$v" ;;
stack_class) STACK_CLASS="$v" ;;
outcome) OUTCOME="$v" ;;
multitask_group) MULTITASK_GROUP="$v" ;;
*) echo "log-convoy-event: ignoring unknown arg '$k'" >&2 ;;
esac
done
if [ -z "$ROLE" ] || [ -z "$CONVOY" ]; then
echo "log-convoy-event: role and convoy are required" >&2
echo "Usage: $0 role=<role> convoy=<slug> [classification=...] [skip_flags=a,b] [duration_s=N] [brief=N] [stack_class=...] [outcome=...]" >&2
exit 1
fi
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Build the event with python3 — handles all string escaping and array encoding
python3 - <<PY >> "$METRICS_FILE"
import json, sys
ev = {
"ts": "$ts",
"role": $(printf '%s' "$ROLE" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"convoy": $(printf '%s' "$CONVOY" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"repo": $(printf '%s' "$REPO_NAME" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"skip_flags": [s for s in "$SKIP_FLAGS".split(",") if s],
}
if "$BRIEF": ev["brief"] = int("$BRIEF")
if "$CLASSIFICATION": ev["classification"] = "$CLASSIFICATION"
if "$DURATION_S": ev["duration_s"] = int("$DURATION_S")
if "$STACK_CLASS": ev["stack_class"] = "$STACK_CLASS"
if "$OUTCOME": ev["outcome"] = "$OUTCOME"
if "$MULTITASK_GROUP": ev["multitask_group"] = "$MULTITASK_GROUP"
print(json.dumps(ev))
PY
echo "Logged: role=$ROLE convoy=$CONVOY → .convoys/.metrics.jsonl"

37
scripts/wt.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# wt.sh — DEPRECATED in Cursor 3.2+.
#
# Cursor 3.2 (Apr 24, 2026) added native worktree management to the
# Agents Window with one-click foregrounding. Use that instead:
# https://cursor.com/docs/configuration/worktrees
#
# This stub is kept for two reasons:
# 1. Pre-3.2 users who haven't upgraded yet.
# 2. Scripted / CI worktree creation outside the IDE.
#
# To create a worktree manually:
# git worktree add -b brief/<convoy>/<N>-<title> \
# "$(dirname "$(git rev-parse --show-toplevel)")/$(basename "$(git rev-parse --show-toplevel)")-worktrees/brief-<N>-<title>" \
# develop
#
# See docs/multitask-playbook.md for when to spin up worktrees vs.
# running implementers in the same checkout.
set -euo pipefail
cat <<'EOF' >&2
wt.sh: deprecated. In Cursor 3.2+ use the Agents Window worktree UI.
Why this is deprecated:
- Cursor 3.2 worktrees integrate with subagent runs and one-click foreground.
- The legacy script duplicates that feature without the integration.
What to do instead:
- In Cursor: open Agents Window → "New worktree" → pick brief branch.
- For CI / scripted use: run `git worktree add` directly.
Reference: docs/multitask-playbook.md (worktrees section)
https://cursor.com/changelog/04-24-26
EOF
exit 0

View file

@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test';
/**
* Smoke tests run against a deployed preview URL.
* BASE_URL is injected by the GitHub Action (PREVIEW_URL).
*
* Add or replace tests here for each critical user path you ship.
* Keep this file fast (<60s total). For deeper E2E, use a separate suite.
*/
const BASE = process.env.BASE_URL ?? 'http://localhost:3000';
test.describe('smoke: app boots and core pages render', () => {
test('home redirects or renders without 5xx', async ({ page }) => {
const response = await page.goto(BASE);
expect(response?.status(), 'home should not 5xx').toBeLessThan(500);
});
test('sign-in page renders', async ({ page }) => {
await page.goto(`${BASE}/login`);
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible({ timeout: 10_000 });
});
test('public health endpoint responds', async ({ request }) => {
const res = await request.get(`${BASE}/api/health`);
expect(res.ok(), `${BASE}/api/health should respond 2xx`).toBeTruthy();
});
});
// Add convoy-specific smoke tests below as features ship. Each new flag-gated
// feature should add a smoke test that exercises the happy path with the flag
// forced on (if your flag wrapper supports query-string overrides).