Bootstrap agent-context pipeline (L1 + L2 + L3)

Installs a 3-layer Cursor-aligned agent pipeline so future agent
sessions can orient quickly and stay inside guard rails:

L1 — context for any agent reading the repo:
  - AGENTS.md (top-level orientation, conventions, no-go zones)
  - .cursor/rules/ (no-go-zones, api-routes, prisma, prisma-schema-map)
  - .cursor/skills/ (add-api-route, add-prisma-model)
  - docs/SCHEMA_MAP.md generated from prisma/schema.prisma
  - scripts/generate-schema-map.ts (regenerate the map; wired up as
    `npm run schema:map`)

L2 — subagent roles for the 9-stage idea-to-feature pipeline:
  - .cursor/agents/role-*.md (conductor, architect, ia-architect,
    design-system-auditor, implementer, reviewer, ux-reviewer,
    a11y-auditor, doc-writer) with explicit multitask annotations.

L3 — pipeline scaffolding:
  - .github/CODEOWNERS, PR template, and CI workflows (ci.yml,
    preview-smoke.yml, visual-diff.yml, pr-health-rollup.yml).
    Test job is intentionally disabled until Playwright is wired up.
  - .convoys/ folder for per-feature run notes + scripts/log-convoy-event.sh.
  - scripts/wt.sh worktree helper.
  - src/lib/flags/index.ts simple env-driven feature flag wrapper.
  - tests/smoke/app.smoke.spec.ts (Playwright smoke; excluded from
    tsc until @playwright/test is installed — see tsconfig change).

Also writes .agent-context-manifest.yml so the sync-agent-context
skill can detect drift and offer selective updates from upstream.

Follow-ups (not in this commit):
  - Install @playwright/test and re-enable the test job in ci.yml.
  - Review .cursor/agents/role-*.md and trim any roles that don't
    apply to this codebase.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-05-23 14:55:18 -05:00
parent 7aedae02fc
commit 9c1aaaa61f
34 changed files with 2909 additions and 1 deletions

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

@ -0,0 +1,133 @@
# .agent-context-manifest.yml
#
# Generated by agent-pipeline. Tracks which artifacts the bootstrap skill
# installed in this repo, where they came from, and what version of the
# pipeline they correspond to.
#
# Read by `sync-agent-context` skill to detect drift and propose updates.
# Don't edit by hand — use the bootstrap or sync skill in Cursor.
schema_version: 1
pipeline_version: "0.3.0"
pipeline_source: "https://github.com/varutasu/agent-pipeline"
installed_at: "2026-05-21T05:37:06Z"
last_synced_at: "2026-05-21T05:37:06Z"
layers:
- L1
- L2
- L3
artifacts:
- path: ".convoys/README.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/convoys-readme.md.template"
version: "0.3.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.3.0"
installed_hash: "sha256:a59938deceb0246ebd7e477f1f9a442102f9fcbb81b0364f0ddc5f86e95a7930"
- path: ".cursor/agents/role-architect.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-architect.md"
version: "0.3.0"
installed_hash: "sha256:6033e27438875e837f2b4b1fd6d56daf3c362948a9ec19bce9d3c1aa2854a90e"
- path: ".cursor/agents/role-conductor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-conductor.md"
version: "0.3.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.3.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.3.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.3.0"
installed_hash: "sha256:69685a3a407c4ee25e2606d426c3107d6b917abee80f907e16ade4a16b439839"
- path: ".cursor/agents/role-implementer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-implementer.md"
version: "0.3.0"
installed_hash: "sha256:b4f4d8596068679b90ffc3a2b6d2e1b6548caf8c68a50f7ed640ba8f638c1c4c"
- path: ".cursor/agents/role-reviewer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-reviewer.md"
version: "0.3.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.3.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.3.0"
installed_hash: "sha256:f6016edb6a84ba6d669dc43f6c683636a5d5bbc5f3c03c51155cc78f8db77cff"
- path: ".cursor/rules/no-go-zones.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/no-go-zones.mdc"
version: "0.3.0"
installed_hash: "sha256:3336cce7ec9fe6c979cd89aee649a08d7af9b487b367ecf29ced644cb8799dcb"
- path: ".cursor/rules/prisma-schema-map.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/prisma-schema-map.mdc.template"
version: "0.3.0"
installed_hash: "sha256:8d72241ec5c7d93a3dba843afdcdffade411698660500aab944525a8d83b7e60"
- path: ".cursor/rules/prisma.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/prisma.mdc.template"
version: "0.3.0"
installed_hash: "sha256:e8f35617033cfebbc0f690739511412e898f72c5ce6a0433ba607ae427a64e9d"
- path: ".cursor/skills/add-api-route/SKILL.md"
source: "skills/bootstrap-agent-context/templates/L1-context/skills/add-api-route/SKILL.md"
version: "0.3.0"
installed_hash: "sha256:36f3c23cf36d13c664c3675030913d1fec276cab2e2f7a811b363f59f63d05ac"
- path: ".cursor/skills/add-prisma-model/SKILL.md"
source: "skills/bootstrap-agent-context/templates/L1-context/skills/add-prisma-model/SKILL.md"
version: "0.3.0"
installed_hash: "sha256:99b34e108a05f93a7e398ddb62dbbe25199d4b9dc363bcde6f8c6aedd5e68017"
- path: ".github/CODEOWNERS"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/CODEOWNERS.template"
version: "0.3.0"
installed_hash: "sha256:736f23a8c82b8008541714bcd87d00212ad7508dcbb6da0c13c435ecc68b5caa"
- path: ".github/PULL_REQUEST_TEMPLATE.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/PULL_REQUEST_TEMPLATE.md.template"
version: "0.3.0"
installed_hash: "sha256:89863e58b9ec194aef1c94d3596e892467833e8bc880a28994acca401b6d9635"
- path: ".github/workflows/ci.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/ci.yml.template"
version: "0.3.0"
installed_hash: "sha256:14f3f1580ee169931fb5b780a3871b866e0531e20df969115ea27f22b8972ca6"
- path: ".github/workflows/pr-health-rollup.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/pr-health-rollup.yml.template"
version: "0.3.0"
installed_hash: "sha256:358d1523da599ee5365058e41aa21fe8deef55ed156d79864b5b0464867b6ff2"
- path: ".github/workflows/preview-smoke.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/preview-smoke.yml.template"
version: "0.3.0"
installed_hash: "sha256:ad882f3cd5b8694ffc6c2572ca7ff900227f3a011fe59fffe437121afd583961"
- path: ".github/workflows/visual-diff.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/visual-diff.yml.template"
version: "0.3.0"
installed_hash: "sha256:c03eb64a9485e816b16aca7fbbc252e47761109ff2627a843bf36b727783685d"
- path: "docs/agent-context/README.md"
source: "skills/bootstrap-agent-context/templates/L1-context/agent-context-readme.md.template"
version: "0.3.0"
installed_hash: "sha256:372776fa0854b107e0aad57878b581b95762e00f06f6c2883ad9a8cf17c1894c"
- path: "scripts/generate-schema-map.ts"
source: "skills/bootstrap-agent-context/templates/L1-context/generate-schema-map.ts"
version: "0.3.0"
installed_hash: "sha256:c2cfd377b23f25d034887649d7565227ea2bcbcbccdc9fca5db0f5123c9b2054"
- path: "scripts/log-convoy-event.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/log-convoy-event.sh"
version: "0.3.0"
installed_hash: "sha256:cd0413691066a177b6b4e6164a9a0978c20a853ad60222ae833b5d53b255818d"
- path: "scripts/wt.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/wt.sh"
version: "0.3.0"
installed_hash: "sha256:2a4f44a159f80a8ea6fe53ac507c01a2f91a4e2118d997a98b051808ac35e9a5"
- path: "src/lib/flags/index.ts"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/flags-index.ts.template"
version: "0.3.0"
installed_hash: "sha256:5073b836d455290e733592dd69383dbf3f027179519ccbfbbfe1a99e00d69da8"
- path: "tests/smoke/app.smoke.spec.ts"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/playwright-smoke.spec.ts.template"
version: "0.3.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).

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,122 @@
---
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>
---
# 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. Append the Architecture section to the convoy file.
12. 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.
## 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.

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,98 @@
---
description: Conventions for Next.js App Router API route handlers in this repo
globs: src/app/api/**/route.ts
---
# API Route Conventions
Every `src/app/api/**/route.ts` follows the same shape: auth → parse body → query Prisma scoped to `orgId` → return JSON, all wrapped in `try / handleApiError`.
## Authentication & Authorization
- Import from `@/lib/api-auth`:
```ts
import { NextRequest, NextResponse } from "next/server";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
// session.user.id, session.user.orgId, session.user.role
// ...
} catch (error) {
return handleApiError(error);
}
}
```
- `requireApiAuthWithOrg()` returns an `OrgSession` (extends NextAuth `Session` with `user.id` and `user.orgId`) — both are always defined after this call.
- Pass an optional `Action` to enforce a permission in one line: `await requireApiAuthWithOrg("cards.delete")`. Throws `PermissionError`; `handleApiError` returns 403.
- For routes that don't need org scoping (rare — typically auth callbacks), use `requireApiAuth()` instead.
## Multi-tenancy is mandatory
Every Prisma query against an org-scoped model (`ResponseCard`, `FormTemplate`, `Person`, `Integration`, …) MUST scope by `organizationId`:
```ts
const card = await prisma.responseCard.findUnique({ where: { id } });
if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
```
For lists: include `organizationId: session.user.orgId` in the `where` filter directly. Returning a 404 (not 403) on cross-org access is the convention so we don't leak existence.
## Request validation
- Body parsing is hand-rolled today. Use the defensive pattern from `src/app/api/cards/[id]/route.ts`:
```ts
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
const stringFields = ["name", "email", /* ... */];
for (const field of stringFields) {
if (body[field] != null) data[field] = String(body[field]);
}
```
- Zod is in `package.json` but not widely used. If you reach for it in a new route, that's fine — just stay consistent within the route.
## Error handling
- Wrap every handler in `try { ... } catch (error) { return handleApiError(error); }`. Never throw to the framework.
- `handleApiError` returns 401 for `ApiAuthError`, 403 for `PermissionError`, 500 (with `console.error`) for anything else.
- For domain errors that aren't auth/permission, return `NextResponse.json({ error: "..." }, { status: 4xx })` directly — don't invent new error classes for one-off cases.
## Database access
- Always `import { prisma } from "@/lib/db";` — the lazy `Proxy` singleton. Never `new PrismaClient()`.
- Use `select` or `include` only when you need it; the default fetch is fine for small models.
- For writes, prefer `update`/`create` over `upsert` unless you actually need both paths.
## Response shape
- Success collections: `NextResponse.json({ items, total, page, limit })` (see `src/app/api/cards/route.ts` GET).
- Success single: `NextResponse.json(record)` (no envelope).
- Created: `NextResponse.json(record, { status: 201 })`.
- Errors: `{ error: string, action?: string }` — `handleApiError` already does this.
## Dynamic routes
Next.js 16 dynamic route params are async. Use:
```ts
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// ...
}
```
## After adding / changing a route
- Update the route table in `README.md` if it's a public-shape change.
- If the route writes to `ResponseCard.firstName` or `lastName`, recompute `name` (see the canonical recompute block in `src/app/api/cards/[id]/route.ts`).

View file

@ -0,0 +1,39 @@
---
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
- `src/generated/` — Prisma client output (regenerate with `npx prisma generate`)
- `*.tsbuildinfo` — TS incremental cache
- `next-env.d.ts` — Next.js generated types
- `tsconfig.tsbuildinfo`
## Append-only / historical
- `prisma/migrations/` — append-only; create **new** migrations via `npx prisma migrate dev --name <descriptive_name>`, never edit existing ones
- `echos_ocr_backup.dump`, `echos_ocr_prod_backup.dump` — database backups, never edit
## Secrets / credentials
- `.env`, `.env.local`, `.env.*.local` — runtime secrets
- Any `*-credentials.json`, `*-service-account-key.json`, `*.pem`, `*.key`
## Local-only / per-developer
- `.vercel/` — local Vercel CLI state
- `.cursor/mcp.json` — personal MCP config (if it appears, do not commit)
## Editing rules of thumb
- New Prisma migrations only: `npx prisma migrate dev --name <descriptive_name>`. Never hand-edit a previous migration's SQL.
- Regenerate the Prisma client after schema changes (`npx prisma generate`) instead of touching `src/generated/`.
- `ResponseCard.name` is denormalized — when writing new code that mutates `firstName` or `lastName`, recompute `name` (see the PUT route in `src/app/api/cards/[id]/route.ts` for the canonical pattern).
- One-off data migrations / backfills go in `scripts/`, not in API routes.

View file

@ -0,0 +1,35 @@
---
description: High-level map of Prisma model groups and where each is used
globs: prisma/**,src/app/api/**/*.ts,src/lib/**/*.ts,scripts/**/*.ts
---
# Prisma Schema Map
Full generated reference: [docs/SCHEMA_MAP.md](../../docs/SCHEMA_MAP.md). Source of truth: [prisma/schema.prisma](../../prisma/schema.prisma) (21 models, 0 enums). Regenerate with `npm run schema:map` after schema changes.
## Model groups (where to look first)
| Group | Anchor models | Where used |
| --- | --- | --- |
| **Auth & Users** | `User`, `Account`, `Session`, `VerificationToken`, `PasswordResetToken` | `src/auth.ts`, `src/app/api/auth/**`, `src/app/(auth)/**` |
| **Org & Membership** | `Organization`, `OrgMember`, `Invitation`, `ApiKey` | `src/lib/api-auth.ts`, `src/app/api/org/**`, `src/app/api/users/**` |
| **Locations & Events** | `Location`, `CollectionDay` | `src/app/(dashboard)/events/**`, `src/app/api/locations/**`, `src/app/api/events/**` |
| **Form Templates** | `FormTemplate`, `FormField` | `src/lib/form-templates.ts`, `src/app/api/form-templates/**`, `src/components/cards/dynamic-field.tsx` |
| **Cards & OCR** | `ResponseCard`, `ProcessingJob` | `src/lib/ocr.ts`, `src/app/api/cards/**`, `src/components/cards/**`, `src/app/(dashboard)/cards/**` |
| **People (CRM)** | `Person` | `src/lib/person-linker.ts`, `src/app/api/people/**`, `src/app/(dashboard)/people/**` |
| **Integrations** | `Integration` | `src/lib/integrations.ts`, `src/lib/integrations/providers/**`, `src/app/api/integrations/**` |
| **Activity & Notifications** | `ActivityLog`, `Notification` | `src/lib/activity-log.ts`, `src/lib/notifications.ts`, `src/app/api/notifications/**` |
| **Settings** | `AppSettings`, `SystemConfig` | `src/app/api/settings/route.ts`, `src/app/(dashboard)/settings/**` |
## Where to look first
- **Adding a model:** edit `prisma/schema.prisma`, run `npx prisma generate`, then `npx prisma migrate dev --name ...`, then `npm run schema:map` to refresh the doc. Add it to the right `MODEL_GROUPS` bucket in `scripts/generate-schema-map.ts` (or let it land in "Other" as a signal you need to categorize).
- **Querying a model:** prefer existing API routes in `src/app/api/<group>/` over rolling new Prisma calls; check `src/lib/` for shared query helpers (`person-linker.ts`, `auto-assign.ts`, `activity-log.ts`).
- **Cross-group lookups:** `ResponseCard` is the hub — it links to `Organization`, `User` (assignedTo / assignedBy / reviewedBy), `FormTemplate`, and `Person`. Query through it with selective `include` / `select`; never `include: { everything }`.
- **Multi-tenancy:** every business model has `organizationId` — see `prisma.mdc` for the mandatory scoping pattern.
## Heuristics for unfamiliar models
- Tables use Prisma's default PascalCase naming (no `@@map`).
- High relation counts (>5) usually indicate a hub model; `ResponseCard` and `User` are the main hubs. Query through these with `include`/`select` selectively.
- A model in the "Other" group in the generated map means somebody added a model but didn't update `MODEL_GROUPS` — promote it to the right group.

74
.cursor/rules/prisma.mdc Normal file
View file

@ -0,0 +1,74 @@
---
description: Prisma schema and database access conventions
globs: prisma/**,src/app/api/**/*.ts,src/lib/**/*.ts,scripts/**/*.ts
---
# Prisma Conventions
## Client import
Always import the singleton:
```ts
import { prisma } from "@/lib/db";
```
`src/lib/db.ts` exposes a lazy `Proxy` over `PrismaClient` — the underlying client (and Postgres pool) is only constructed on first property access. Never `new PrismaClient()` outside of `src/lib/db.ts`. Don't call `prisma` from top-level module code; the lazy proxy depends on `DATABASE_URL` being set at access time, not import time.
## Generated client path
The Prisma client is generated to `src/generated/prisma/` (custom `output` in `prisma/schema.prisma`), not `@prisma/client`. To import generated types or enums:
```ts
import { Prisma, PrismaClient } from "@/generated/prisma/client";
```
`src/generated/` is a no-go zone — regenerate via `npx prisma generate` instead of editing.
## Schema patterns (what this repo actually does)
- **IDs:** `String @id @default(cuid())`.
- **Timestamps:** `createdAt DateTime @default(now())` + `updatedAt DateTime @updatedAt`.
- **Multi-tenancy:** every business model has `organizationId String` + `@@index([organizationId])`. Auth tables (`User`, `Account`, `Session`) and the `Organization` itself are the exceptions.
- **JSON columns:** `Json?` is used for `ResponseCard.fieldData`, `ResponseCard.rawOcrResponse`, `Integration.config`, etc. Treat the shape as untrusted on read.
- **Indexes:** `@@index` on FKs and the columns we sort/filter by. Add an index when you add a `where:` filter.
- **No `@@map`:** Prisma defaults to PascalCase tables here; do not add `@@map(...)` to existing models.
## Multi-tenant scoping (mandatory)
Every read or write against a business model MUST scope by `organizationId`:
```ts
// Read
const items = await prisma.responseCard.findMany({
where: { organizationId: session.user.orgId, /* ... */ },
});
// Create
await prisma.responseCard.create({
data: { organizationId: session.user.orgId, /* ... */ },
});
```
Cross-org leakage is a security bug. Returning a `404` (not `403`) on accidental cross-org IDs is the convention so we don't reveal existence.
## After schema changes
```bash
npx prisma generate # regenerate client at src/generated/prisma/
npx prisma migrate dev --name descriptive_name # create + apply a migration locally
npm run schema:map # refresh docs/SCHEMA_MAP.md (see prisma-schema-map.mdc)
```
For prototype-stage edits (no DB structure change you intend to keep), `npm run db:push` is also available.
## Query best practices
- `findUnique` for ID / unique lookups; `findFirst` when filtering by org.
- Use `select` when you only need a few fields and the model has heavy relations.
- Wrap multi-step writes in `prisma.$transaction([...])` or `prisma.$transaction(async (tx) => ...)`.
- Background jobs go through `ProcessingJob` — see `src/lib/ocr.ts` for the canonical pattern.
## Denormalized fields
`ResponseCard.name` is a denormalized display string derived from `firstName + lastName`. Three places set it: OCR pipeline (`src/lib/ocr.ts`), the public survey submit handler (`src/app/api/survey/submit/route.ts`), and the PUT route (`src/app/api/cards/[id]/route.ts`, which recomputes on any first/last change). When you write a new path that mutates `firstName` or `lastName`, recompute `name` too — or call into the PUT path so it does it for you.

View file

@ -0,0 +1,156 @@
---
name: add-api-route
description: Add a new Next.js App Router API route in src/app/api/**/route.ts following the repo's auth + multi-tenancy + error-handling conventions. Use when the user asks to add an endpoint, route, handler, GET/POST/PUT/DELETE, /api/X, or a server action backed by Prisma.
---
# Add a new API route
Every API route in this repo is a `src/app/api/<segments>/route.ts` file that exports `GET` / `POST` / `PUT` / `DELETE`. They all follow the same shape: auth check → org-scoped Prisma access → JSON response, wrapped in `try / handleApiError`.
## Recipe
### 1. Pick the path
App Router maps directories to URL segments. Dynamic segments use `[param]`:
| Want | File |
| --- | --- |
| `GET /api/widgets` | `src/app/api/widgets/route.ts` |
| `GET /api/widgets/[id]` | `src/app/api/widgets/[id]/route.ts` |
| `POST /api/widgets/[id]/archive` | `src/app/api/widgets/[id]/archive/route.ts` |
### 2. Start from this template
```ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
const [items, total] = await Promise.all([
prisma.widget.findMany({
where: { organizationId: session.user.orgId },
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.widget.count({ where: { organizationId: session.user.orgId } }),
]);
return NextResponse.json({ items, total, page, limit });
} catch (error) {
return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg("widgets.create");
const body = await request.json().catch(() => ({}));
if (!body.name) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const widget = await prisma.widget.create({
data: {
organizationId: session.user.orgId,
name: String(body.name),
},
});
return NextResponse.json(widget, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}
```
### 3. Dynamic route params are async in Next.js 16
```ts
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await params;
const widget = await prisma.widget.findUnique({ where: { id } });
if (!widget || widget.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Widget not found" }, { status: 404 });
}
return NextResponse.json(widget);
} catch (error) {
return handleApiError(error);
}
}
```
Cross-org returns 404, not 403 (don't reveal existence).
### 4. Permission gating
Two ways to enforce a permission:
```ts
// Inline in the auth call — throws PermissionError → handleApiError returns 403
const session = await requireApiAuthWithOrg("cards.delete");
```
```ts
// Or check explicitly when the policy is fancier
import { can } from "@/lib/permissions";
const session = await requireApiAuthWithOrg();
if (!can(session.user.role, "cards.edit") && card.assignedToId !== session.user.id) {
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
}
```
`Action` is a union type in `src/lib/permissions.ts` — TypeScript will autocomplete the valid actions. Add a new action there if you need one.
### 5. Body handling
Hand-rolled is the prevailing style. Use the defensive pattern:
```ts
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
for (const field of ["name", "description"]) {
if (body[field] != null) data[field] = String(body[field]);
}
if (body.published != null) data.published = Boolean(body.published);
```
For complex shapes, Zod is in `package.json` — using it in a new route is fine, just stay consistent within the file.
### 6. Updating denormalized `ResponseCard.name`
If your route mutates `ResponseCard.firstName` or `lastName`, you MUST recompute `name`. The canonical block lives in `src/app/api/cards/[id]/route.ts` — copy it:
```ts
if (("firstName" in data || "lastName" in data) && !("name" in data)) {
const nextFirst = "firstName" in data ? (data.firstName as string | null) : card.firstName;
const nextLast = "lastName" in data ? (data.lastName as string | null) : card.lastName;
const combined = [nextFirst, nextLast].filter(Boolean).join(" ").trim();
data.name = combined || null;
}
```
### 7. After writing the route
- Verify locally with `curl` or the Network tab.
- If it's a user-visible endpoint, add a row to the API table in `README.md`.
- Run `npm run lint` and `npx tsc --noEmit` before opening the PR.
## Anti-patterns
- Skipping `organizationId` in `where:` filters → cross-org data leak.
- `throw` instead of `return handleApiError(error)` → uncaught error in the framework.
- Returning `403` for cross-org IDs instead of `404` → leaks existence.
- Calling `prisma` from top-level module code → the lazy proxy needs `DATABASE_URL` at access time.
- Adding `"use client"` to a route file → server-only.
- Hand-writing `NextResponse.json({ error }, { status: 500 })` in a catch block → use `handleApiError`.

View file

@ -0,0 +1,113 @@
---
name: add-prisma-model
description: Add a new Prisma model to prisma/schema.prisma following the repo's multi-tenant conventions, then regenerate the client, create a migration, and refresh the schema map. Use when the user asks to add a model, table, entity, schema, or new database object.
---
# Add a new Prisma model
Every business model in this repo is multi-tenant (scoped by `organizationId`), uses `cuid()` IDs, has `createdAt` / `updatedAt`, and gets indexed on its FK columns. The schema map (`docs/SCHEMA_MAP.md`) is a generated artifact — refresh it after schema edits.
## Recipe
### 1. Edit `prisma/schema.prisma`
Add the model near related models (keep the section comment dividers intact). Template for a typical business model:
```prisma
model Widget {
id String @id @default(cuid())
organizationId String
name String
description String?
config Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([organizationId, name])
}
```
Then add the back-relation on `Organization`:
```prisma
model Organization {
// ...existing fields...
widgets Widget[]
}
```
If the new model is owned by a user (e.g. a comment), add `userId` + `@relation` + `onDelete: Cascade` (or `SetNull`, depending) and the back-relation on `User`.
Auth tables (`User`, `Account`, `Session`) and `Organization` itself are the only models without `organizationId`.
### 2. Regenerate the Prisma client
```bash
npx prisma generate
```
This rewrites `src/generated/prisma/`. The client is consumed via `import { prisma } from "@/lib/db"` — no other file should change.
### 3. Create a migration
For DB changes you intend to keep:
```bash
npx prisma migrate dev --name add_widget_model
```
For prototype-stage edits (no migration yet):
```bash
npm run db:push
```
Once you're settling on a shape, switch to migrations — `prisma/migrations/` is append-only and authoritative for production.
### 4. Refresh the schema map
```bash
npm run schema:map
```
This regenerates `docs/SCHEMA_MAP.md` from `prisma/schema.prisma`. New models land in the **Other** group by default. To categorize:
1. Open `scripts/generate-schema-map.ts`.
2. Add the model to the right `MODEL_GROUPS` bucket (or add a new bucket).
3. Re-run `npm run schema:map`.
### 5. Wire it into API + UI
- API routes: follow the `add-api-route` skill. Every query MUST scope by `organizationId`.
- Activity log: if the model represents user-meaningful state, fire `logActivity(...)` from `src/lib/activity-log.ts` on create / update / delete.
- Integrations: if the model should propagate to Planning Center / Monday / etc., add an event in `src/lib/integrations.ts` and a handler in the relevant provider under `src/lib/integrations/providers/`.
## Common patterns by model shape
### Soft delete / archive
Don't introduce a `deletedAt` column unless there's a real use case — this repo uses hard delete + `ActivityLog` for audit trail. If you do need soft delete, add `archivedAt DateTime?` (not `deletedAt`) and remember to filter `archivedAt: null` in every list query.
### JSON config column
Lots of models have a `Json?` column (`Integration.config`, `ResponseCard.fieldData`, etc.) — perfect for variable shapes. Treat as untrusted on read; validate / coerce before use. Don't try to query inside JSON with raw SQL.
### Denormalized display field
If you add a model with a denormalized display string (like `ResponseCard.name`), establish the recompute path in the SAME PR — both server-side (any write path) and any UI that reads it. See the cautionary tale in `AGENTS.md` § 4.
### Lookup table for enums
Prisma enums exist but this repo doesn't use any. Convention here: `String` column with documented allowed values + a TypeScript union in `src/lib/<area>.ts`. Stay consistent.
## Anti-patterns
- Forgetting `organizationId` → cross-tenant data leak waiting to happen.
- Hand-editing a previous migration → `prisma/migrations/` is append-only; create a new migration.
- Editing files under `src/generated/prisma/` → regenerate instead.
- Adding `@@map("...")` to a single model → none of the existing models use it; keep the schema consistent.
- Skipping `npm run schema:map` after adding the model → `docs/SCHEMA_MAP.md` drifts immediately.
- Adding a back-relation on `Organization` without thinking about cascade behavior → `onDelete: Cascade` is usually right; `SetNull` is sometimes right; `Restrict` almost never.

30
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,30 @@
# CODEOWNERS — review routing for the agent pipeline.
#
# REPLACE @varutasu below with your actual GitHub handle (or a team
# handle like @org/team-name) before merging this file. Until you do, GitHub
# will show "unknown owner" warnings on PRs but won't block them.
#
# Pattern: high-risk paths require explicit human review; others are advisory.
# Global default — every PR notifies these reviewers
* @varutasu
# High-risk: schema, migrations, auth, money — require review
prisma/schema.prisma @varutasu
prisma/migrations/** @varutasu
auth.ts @varutasu
lib/auth-options.ts @varutasu
lib/auth/** @varutasu
middleware.ts @varutasu
lib/flags/** @varutasu
# CI / infra — require review
.github/workflows/** @varutasu
cloudbuild*.yaml @varutasu
Dockerfile* @varutasu
next.config.* @varutasu
# Agent context — owner should approve changes here so the pipeline stays consistent
.cursor/agents/** @varutasu
.cursor/rules/** @varutasu
AGENTS.md @varutasu

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

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

@ -0,0 +1,98 @@
name: CI
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-types-build:
name: Lint, types, build
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
- name: Generate Prisma client
run: npx prisma generate
- run: npm run lint
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
env:
# Build-time env. Real values come from Cloud Run / Vercel — set non-empty defaults
# here so the build doesn't crash on missing required vars.
DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
NEXTAUTH_SECRET: ci-secret-only-for-build
NEXTAUTH_URL: http://localhost:3000
# NOTE: echos-ocr has no test runner configured yet (no vitest / jest /
# playwright in package.json). Re-enable this job once a test runner is
# adopted and a `test:run` script exists in package.json. Until then,
# this block stays commented so CI doesn't fail on a missing script.
#
# test:
# name: Unit + integration tests
# runs-on: ubuntu-latest
# services:
# postgres:
# image: postgres:16
# env:
# POSTGRES_USER: ci
# POSTGRES_PASSWORD: ci
# POSTGRES_DB: ci
# ports: ['5432:5432']
# options: >-
# --health-cmd "pg_isready -U ci"
# --health-interval 5s
# --health-timeout 5s
# --health-retries 10
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: ${{ env.NODE_VERSION }}
# cache: npm
# - run: npm ci
# - run: npx prisma generate
# - name: Run migrations
# run: npx prisma migrate deploy
# env:
# DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
# - run: npm run test:run
# env:
# DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
# NEXTAUTH_SECRET: ci-secret-only-for-tests
# NEXTAUTH_URL: http://localhost:3000
schema-map-fresh:
name: Schema map up to date
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
- name: Regenerate schema map
run: npm run schema:map
- name: Verify no drift
run: |
if ! git diff --quiet docs/SCHEMA_MAP.md; then
echo "::error::docs/SCHEMA_MAP.md is out of date. Run 'npm run schema:map' and commit."
git diff docs/SCHEMA_MAP.md
exit 1
fi

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

@ -0,0 +1,93 @@
name: PR Health rollup
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
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 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('CI: Lint, types, build', find('Lint, types, build')),
// CI: Tests row omitted — echos-ocr has no test runner yet.
// Re-enable once a `test:run` script lands in package.json and
// the `test:` job in ci.yml is uncommented.
row('CI: Schema map fresh', find('Schema map up to date')),
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### CI gates\n\n| Gate | Status |\n| --- | --- |\n${rows.join('\n')}\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 });
}

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

@ -0,0 +1,79 @@
name: Preview smoke
on:
pull_request:
branches: [main]
types: [labeled, opened, synchronize, reopened]
# Only run when:
# - The PR has the 'preview-ready' label, OR
# - The PR body / commits do not contain 'pipeline:.*skip.*smoke'
#
# Skip semantics: convoy-classified docs/infra/config-only PRs add 'skip: smoke'
# to the body and this job no-ops via the gate step below.
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
env:
# Set this in repo secrets/vars to your preview URL pattern, e.g.
# https://pr-${{ github.event.pull_request.number }}.preview.example.com
PREVIEW_URL: ${{ vars.PREVIEW_URL_PATTERN }}
steps:
- uses: actions/checkout@v4
- 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: Wait for preview to respond
run: |
PREVIEW="${PREVIEW_URL//\$\{PR\}/${{ github.event.pull_request.number }}}"
for i in {1..30}; do
if curl -fsS "$PREVIEW" > /dev/null; then
echo "Preview ready at $PREVIEW"
echo "PREVIEW_RESOLVED=$PREVIEW" >> $GITHUB_ENV
exit 0
fi
echo "Waiting for preview ($i/30)..."
sleep 10
done
echo "::error::Preview never responded at $PREVIEW"
exit 1
- name: Run smoke tests
run: npx playwright test --project=smoke
env:
BASE_URL: ${{ env.PREVIEW_RESOLVED }}
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

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

@ -0,0 +1,76 @@
name: Visual diff
on:
pull_request:
branches: [main]
paths:
- 'src/app/**'
- 'src/components/**'
- 'src/app/globals.css'
- 'postcss.config.*'
# Skip when PR body contains 'pipeline: ... skip ... visual'
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
env:
PREVIEW_URL: ${{ vars.PREVIEW_URL_PATTERN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Resolve preview URL
run: echo "PREVIEW_RESOLVED=${PREVIEW_URL//\$\{PR\}/${{ github.event.pull_request.number }}}" >> $GITHUB_ENV
- name: Capture screenshots (PR)
run: npx playwright test --project=visual --update-snapshots=none
env:
BASE_URL: ${{ env.PREVIEW_RESOLVED }}
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.`
});

3
.gitignore vendored
View file

@ -46,3 +46,6 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# convoy self-analytics (local-only; remove this line to opt-in to commits)
.convoys/.metrics.jsonl

68
AGENTS.md Normal file
View file

@ -0,0 +1,68 @@
# AGENTS.md — AI collaboration (Echo OCR)
Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.
## 1. Project overview
Echo OCR is a Next.js + Prisma app for Echo Life Church. It ingests paper "connect cards" via OCR (scanned PDFs / images / public survey submissions), extracts structured data, stores it in PostgreSQL behind a multi-tenant org model, and serves a filterable review UI with integrations to Planning Center, Monday.com, Airtable, and webhooks.
- **Framework:** Next.js 16 (App Router) + React 19, TypeScript strict
- **Data:** Prisma 7 (custom client output at `src/generated/prisma/`) + PostgreSQL via `pg` + `@prisma/adapter-pg`
- **Auth:** NextAuth 5 beta — session in `src/auth.ts`; server-side `auth()` wrapped by `requireApiAuthWithOrg` in `src/lib/api-auth.ts`
- **UI:** Tailwind v4 + shadcn/ui primitives in `src/components/ui/`; `lucide-react` icons; `sonner` toasts
- **OCR / AI:** Ollama vision models (local) and the `ai` SDK with OpenAI-compatible gateways
- **Storage:** S3-compatible (MinIO local, anything S3 in prod) via `@aws-sdk/client-s3`
- **Hosting:** Vercel (`vercel.json`) primary; Dockerfile + `docker-compose.yml` for Coolify / local
## 2. Architecture quick reference
| Area | Path | Notes |
| --- | --- | --- |
| App pages | `src/app/(dashboard)/`, `src/app/(auth)/`, `src/app/(marketing)/` | Route groups; dashboard is the protected app shell |
| API routes | `src/app/api/**/route.ts` | All start with `requireApiAuthWithOrg()`; errors via `handleApiError` |
| Prisma client | `src/lib/db.ts` | Lazy proxy singleton; **import `prisma` from `@/lib/db`** (never instantiate `new PrismaClient()`) |
| Auth helpers | `src/lib/api-auth.ts`, `src/auth.ts` | `requireApiAuthWithOrg(action?)` returns an `OrgSession` with `user.id` + `user.orgId` |
| Permissions | `src/lib/permissions.ts` | `can(role, action)` and `Action` union; roles: `owner > admin > editor > reviewer > viewer` |
| UI primitives | `src/components/ui/` | shadcn-style; do not duplicate — extend or compose |
| Feature components | `src/components/cards/`, `src/components/forms/`, etc. | Co-located by feature |
| OCR pipeline | `src/lib/ocr.ts`, `src/lib/ai-ocr.ts`, `src/lib/ollama.ts` | Background job model in `ProcessingJob` |
| Integrations | `src/lib/integrations/providers/` | Each provider exports the same shape; fired via `fireIntegrationEvent()` |
| Schema | `prisma/schema.prisma` | 21 models, no enums; multi-tenant via `organizationId` |
| Schema map | `docs/SCHEMA_MAP.md` | Regenerate with `npm run schema:map` |
Generated Prisma client lives at `src/generated/prisma/` — do not edit; regenerate with `npx prisma generate`.
## 3. Key conventions
- **Auth (server):** `const session = await requireApiAuthWithOrg(); /* session.user.orgId */`. Pass an optional `Action` to enforce a permission inline; `handleApiError` returns the right 401/403/500.
- **Multi-tenancy:** every `ResponseCard`, `FormTemplate`, `Person`, etc. is scoped by `organizationId`. **Always** include `organizationId: session.user.orgId` in `where:` filters and create payloads — there is no row-level security in dev.
- **API errors:** wrap handlers in `try { ... } catch (error) { return handleApiError(error); }`. Don't hand-roll `NextResponse.json({ error }, { status: 500 })`.
- **Validation:** body parsing is hand-rolled with defensive defaults (see `src/app/api/cards/[id]/route.ts` PUT). Zod is in `package.json` but not widely adopted yet — match the surrounding file's style.
- **Form templates:** field keys that match a top-level `ResponseCard` column must be `isCore: true` (see `src/lib/form-templates.ts`). Non-core values live in `ResponseCard.fieldData` JSON; the PUT route auto-promotes matching keys to columns.
- **Toasts:** `import { toast } from "sonner"``toast.success`, `toast.error`, `toast.info`.
- **Imports:** `@/*``src/*` (`tsconfig.json` `paths`). Generated Prisma at `@/generated/prisma/...`.
- **File names:** kebab-case for routes (`reprocess-batch/`), kebab-case for components (`upload-modal.tsx`), camelCase for hooks (`useUserProfile`).
- **Client vs server:** every interactive page starts with `"use client"`; API routes never include it.
## 4. Common gotchas
- `ResponseCard.name` is a **denormalized** display string. The PUT route at `src/app/api/cards/[id]/route.ts` recomputes it from `firstName + lastName` on edit; the OCR pipeline and `survey/submit` populate it on create. If you write a new path that mutates first/last, recompute `name` too.
- Prisma client lives at `src/generated/prisma/` (custom output, not the default `@prisma/client`). Import enums and types from there.
- `src/lib/db.ts` exports a **lazy `Proxy`** so importing `prisma` doesn't open a DB pool at module load — required for Next.js build-time data collection without `DATABASE_URL`. Don't call `prisma` from top-level module code.
- MinIO/S3 images are served through `GET /api/images/[...path]` which presigns and proxies. Don't hand back raw S3 URLs to the client.
- The Cloudflare/Vercel route `src/middleware.ts` enforces auth before requests hit `src/app/...`. New unauthenticated routes must be allowlisted there.
- `prisma/schema.prisma` does **not** declare a `url`. The connection string comes from `DATABASE_URL` at runtime — local dev needs `.env`.
## 5. Running locally
- **Runtime:** Node 18+ (Node 20 LTS recommended).
- **Setup:** see [`README.md`](README.md) (`docker compose up -d postgres minio`, `npm run db:push`, `npm run dev`). Requires Ollama with a vision model (`ollama pull llava:7b`) and GraphicsMagick for PDF rasterization.
- **Dev server:** `npm run dev`. App at `http://localhost:3000`, MinIO console at `:9001`.
## 6. Testing
- **Runner:** none configured yet. There are no unit, integration, or E2E tests. Adding tests is welcome — start with `vitest` and `@playwright/test`; gate them in CI before requiring green.
## 7. Deployment
- Primary target is Vercel (`vercel.json`). `Dockerfile` + `docker-compose.yml` are for Coolify / self-host. Build step is `npx prisma generate && next build` (see `package.json`). Notes on Coolify / Postgres / MinIO / Traefik wiring are in [`server_deploy.md`](../server_deploy.md) at the workspace root.

206
docs/SCHEMA_MAP.md Normal file
View file

@ -0,0 +1,206 @@
# Prisma Schema Map
_Auto-generated by `npm run schema:map` from [prisma/schema.prisma](../prisma/schema.prisma). Do not edit by hand._
Models: **21** • Enums: **0** • Groups: **9**
## Table of contents
- [Auth & Users](#auth-users) (5)
- [Org & Membership](#org-membership) (4)
- [Locations & Events](#locations-events) (2)
- [Form Templates](#form-templates) (2)
- [Cards & OCR](#cards-ocr) (2)
- [People (CRM)](#people-crm-) (1)
- [Integrations](#integrations) (1)
- [Activity & Notifications](#activity-notifications) (2)
- [Settings](#settings) (2)
## Auth & Users
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **Account** | `—` | 1 | [User](#auth-users) |
| **PasswordResetToken** | `—` | 0 | — |
| **Session** | `—` | 1 | [User](#auth-users) |
| **User** | `—` | 8 | [Account](#auth-users), [ActivityLog](#activity-notifications), [Notification](#activity-notifications), [OrgMember](#org-membership), [ResponseCard](#cards-ocr), [Session](#auth-users) |
| **VerificationToken** | `—` | 0 | — |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
Account "1" ||--|| "1" User : user
PasswordResetToken {
string id
}
Session "1" ||--|| "1" User : user
User "1" ||--o{ "many" OrgMember : memberships
User "1" ||--o{ "many" ResponseCard : assignedCards
User "1" ||--o{ "many" ActivityLog : activityLogs
User "1" ||--o{ "many" Notification : notifications
VerificationToken {
string id
}
```
</details>
## Org & Membership
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **ApiKey** | `—` | 1 | [Organization](#org-membership) |
| **Invitation** | `—` | 1 | [Organization](#org-membership) |
| **Organization** | `—` | 9 | [ApiKey](#org-membership), [FormTemplate](#form-templates), [Integration](#integrations), [Invitation](#org-membership), [Location](#locations-events), [OrgMember](#org-membership), [Person](#people-crm-), [ProcessingJob](#cards-ocr), [ResponseCard](#cards-ocr) |
| **OrgMember** | `—` | 2 | [Organization](#org-membership), [User](#auth-users) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
ApiKey "1" ||--|| "1" Organization : organization
Invitation "1" ||--|| "1" Organization : organization
Organization "1" ||--o{ "many" Location : locations
Organization "1" ||--o{ "many" OrgMember : members
Organization "1" ||--o{ "many" ResponseCard : cards
Organization "1" ||--o{ "many" ProcessingJob : jobs
Organization "1" ||--o{ "many" Integration : integrations
Organization "1" ||--o{ "many" FormTemplate : formTemplates
Organization "1" ||--o{ "many" Person : persons
OrgMember "1" ||--|| "1" User : user
```
</details>
## Locations & Events
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **CollectionDay** | `—` | 2 | [Location](#locations-events), [ResponseCard](#cards-ocr) |
| **Location** | `—` | 3 | [CollectionDay](#locations-events), [Organization](#org-membership), [ResponseCard](#cards-ocr) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
CollectionDay "1" ||--|| "1" Location : location
CollectionDay "1" ||--o{ "many" ResponseCard : cards
Location "1" ||--|| "1" Organization : organization
Location "1" ||--o{ "many" ResponseCard : cards
```
</details>
## Form Templates
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **FormField** | `—` | 1 | [FormTemplate](#form-templates) |
| **FormTemplate** | `—` | 3 | [FormField](#form-templates), [Organization](#org-membership), [ResponseCard](#cards-ocr) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
FormField "1" ||--|| "1" FormTemplate : formTemplate
FormTemplate "1" ||--|| "1" Organization : organization
FormTemplate "1" ||--o{ "many" ResponseCard : cards
```
</details>
## Cards & OCR
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **ProcessingJob** | `—` | 1 | [Organization](#org-membership) |
| **ResponseCard** | `—` | 8 | [CollectionDay](#locations-events), [FormTemplate](#form-templates), [Location](#locations-events), [Organization](#org-membership), [Person](#people-crm-), [User](#auth-users) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
ProcessingJob "1" ||--|| "1" Organization : organization
ResponseCard "1" ||--|| "1" User : assignedTo
ResponseCard "1" ||--|| "1" Organization : organization
ResponseCard "1" ||--|| "1" Location : location
ResponseCard "1" ||--|| "1" CollectionDay : collectionDay
ResponseCard "1" ||--|| "1" FormTemplate : formTemplate
ResponseCard "1" ||--|| "1" Person : person
```
</details>
## People (CRM)
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **Person** | `—` | 4 | [Organization](#org-membership), [Person](#people-crm-), [ResponseCard](#cards-ocr) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
Person "1" ||--|| "1" Organization : organization
Person "1" ||--o{ "many" ResponseCard : cards
```
</details>
## Integrations
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **Integration** | `—` | 1 | [Organization](#org-membership) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
Integration "1" ||--|| "1" Organization : organization
```
</details>
## Activity & Notifications
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **ActivityLog** | `—` | 1 | [User](#auth-users) |
| **Notification** | `—` | 1 | [User](#auth-users) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
ActivityLog "1" ||--|| "1" User : user
Notification "1" ||--|| "1" User : user
```
</details>
## Settings
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **AppSettings** | `—` | 0 | — |
| **SystemConfig** | `—` | 0 | — |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
AppSettings {
string id
}
SystemConfig {
string id
}
```
</details>
---
Regenerate after schema changes: `npm run schema:map`. Curated groupings live in [scripts/generate-schema-map.ts](../scripts/generate-schema-map.ts) under `MODEL_GROUPS`.

View file

@ -0,0 +1,49 @@
# 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 `src/app/api/**`) | 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) | Generated Prisma model reference grouped by feature area | Loaded when prisma-schema-map.mdc matches |
## 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 (e.g. `api-routes.mdc` enforces `requireApiAuthWithOrg` + `handleApiError`).
4. **Verify.** `npm run lint` and `npx tsc --noEmit`.
## 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) |
| Refresh the schema map after a Prisma change | `npm run schema:map` (regenerates `docs/SCHEMA_MAP.md`) |
| Re-categorize a Prisma model in the schema map | Edit `MODEL_GROUPS` in [`scripts/generate-schema-map.ts`](../../scripts/generate-schema-map.ts), then `npm run schema:map` |
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.
## The L2 subagent pipeline
In addition to the L1 context above, `.cursor/agents/role-*.md` defines 9 subagent roles for an idea-to-feature pipeline (conductor → ia-architect → ux-reviewer → architect → implementer → reviewer + design-system-auditor + a11y-auditor → doc-writer). Each role is read on invocation; they don't add to the always-on token budget. See `.cursor/agents/role-conductor.md` to get started.
Convoy files live in [`.convoys/`](../../.convoys/) — see that folder's README for the convoy lifecycle.
## What's intentionally NOT here
- No commits of `.cursor/mcp.json` (per-developer MCP install is personal-only, in `~/.cursor/mcp.json`).
- No proprietary cloud-credential bundles.
- No vendored screenshots / model weights — these belong in their own repos.
## Pipeline manifest
`.agent-context-manifest.yml` at the repo root tracks which artifacts the bootstrap installed, where they came from, and their pipeline version. Don't edit by hand — use the `sync-agent-context` skill to apply updates.
Pipeline source: [varutasu/agent-pipeline](https://github.com/varutasu/agent-pipeline). Multitask playbook (audit fan-out + implementer fleets via Cursor 3.2 `/multitask`): [docs/multitask-playbook.md](https://github.com/varutasu/agent-pipeline/blob/main/docs/multitask-playbook.md).

View file

@ -10,6 +10,7 @@
"db:migrate": "npx prisma migrate dev",
"db:push": "npx prisma db push",
"db:studio": "npx prisma studio",
"schema:map": "npx tsx scripts/generate-schema-map.ts",
"postinstall": "npx prisma generate"
},
"dependencies": {

View file

@ -0,0 +1,291 @@
#!/usr/bin/env ts-node
/**
* generate-schema-map.ts
*
* Parses prisma/schema.prisma and emits docs/SCHEMA_MAP.md a grouped
* reference of all Prisma models with relation counts and per-group mermaid
* ER diagrams. Run via `npm run schema:map` after schema changes.
*
* MODEL_GROUPS is the curated source of truth for how Echo OCR's models
* cluster by feature area. New models fall into the "Other" bucket so the
* map stays complete even when this file is out of date that's a useful
* signal that you forgot to categorize a new model, so leave it that way.
*/
import { promises as fs } from "fs";
import * as path from "path";
const REPO_ROOT = path.resolve(__dirname, "..");
const SCHEMA_PATH = path.join(REPO_ROOT, "prisma", "schema.prisma");
const OUTPUT_PATH = path.join(REPO_ROOT, "docs", "SCHEMA_MAP.md");
interface ParsedField {
name: string;
type: string;
isRelation: boolean;
isList: boolean;
isOptional: boolean;
attributes: string;
}
interface ParsedModel {
name: string;
block: string;
fields: ParsedField[];
tableMap?: string;
comment?: string;
}
interface ParsedEnum {
name: string;
values: string[];
}
const MODEL_GROUPS: Record<string, string[]> = {
"Auth & Users": ["User", "Account", "Session", "VerificationToken", "PasswordResetToken"],
"Org & Membership": ["Organization", "OrgMember", "Invitation", "ApiKey"],
"Locations & Events": ["Location", "CollectionDay"],
"Form Templates": ["FormTemplate", "FormField"],
"Cards & OCR": ["ResponseCard", "ProcessingJob"],
"People (CRM)": ["Person"],
"Integrations": ["Integration"],
"Activity & Notifications": ["ActivityLog", "Notification"],
"Settings": ["AppSettings", "SystemConfig"],
};
const GROUP_ORDER = Object.keys(MODEL_GROUPS);
function parseSchema(source: string): {
models: ParsedModel[];
enums: ParsedEnum[];
} {
const models: ParsedModel[] = [];
const enums: ParsedEnum[] = [];
const modelRegex = /(?:^|\n)((?:\/\/\/[^\n]*\n)*)model\s+(\w+)\s*\{([\s\S]*?)\n\}/g;
let m: RegExpExecArray | null;
while ((m = modelRegex.exec(source)) !== null) {
const [, leadingComments, name, body] = m;
const fields: ParsedField[] = [];
let tableMap: string | undefined;
const lines = body.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("//")) continue;
if (line.startsWith("@@map(")) {
const mm = line.match(/@@map\("([^"]+)"\)/);
if (mm) tableMap = mm[1];
continue;
}
if (line.startsWith("@@")) continue;
const fieldMatch = line.match(/^(\w+)\s+([\w\[\]?]+)(\s+.*)?$/);
if (!fieldMatch) continue;
const [, fieldName, rawType, attrs] = fieldMatch;
const isList = rawType.endsWith("[]");
const isOptional = rawType.endsWith("?");
const baseType = rawType.replace(/[\[\]?]/g, "");
const isRelation = /^[A-Z]/.test(baseType);
fields.push({
name: fieldName,
type: baseType,
isRelation,
isList,
isOptional,
attributes: (attrs ?? "").trim(),
});
}
const comment = leadingComments
? leadingComments
.split("\n")
.map((l) => l.replace(/^\/\/\/\s?/, "").trim())
.filter(Boolean)
.join(" ")
: undefined;
models.push({ name, block: body, fields, tableMap, comment });
}
const enumRegex = /(?:^|\n)enum\s+(\w+)\s*\{([\s\S]*?)\n\}/g;
while ((m = enumRegex.exec(source)) !== null) {
const [, name, body] = m;
const values = body
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("//"))
.map((l) => l.split(/\s+/)[0]);
enums.push({ name, values });
}
return { models, enums };
}
function groupOf(modelName: string): string {
for (const [group, names] of Object.entries(MODEL_GROUPS)) {
if (names.includes(modelName)) return group;
}
return "Other";
}
function relationTargets(model: ParsedModel, scalarTypes: Set<string>): string[] {
const targets = new Set<string>();
for (const f of model.fields) {
if (!f.isRelation) continue;
if (scalarTypes.has(f.type)) continue;
targets.add(f.type);
}
return Array.from(targets).sort();
}
function relationCount(model: ParsedModel, scalarTypes: Set<string>): number {
return model.fields.filter((f) => f.isRelation && !scalarTypes.has(f.type)).length;
}
function buildMermaid(group: string, models: ParsedModel[], allModelNames: Set<string>): string {
if (models.length === 0) return "";
const lines: string[] = ["```mermaid", "erDiagram"];
const seenEdges = new Set<string>();
for (const model of models) {
for (const f of model.fields) {
if (!f.isRelation) continue;
if (!allModelNames.has(f.type)) continue;
const a = model.name;
const b = f.type;
if (a === b) continue;
const edgeKey = [a, b].sort().join("--");
if (seenEdges.has(edgeKey)) continue;
seenEdges.add(edgeKey);
const cardinality = f.isList ? '"1" ||--o{ "many"' : '"1" ||--|| "1"';
lines.push(` ${a} ${cardinality} ${b} : ${f.name}`);
}
if (
!models.some((other) =>
other.fields.some((ff) => ff.isRelation && ff.type === model.name)
) &&
!model.fields.some((ff) => ff.isRelation && allModelNames.has(ff.type))
) {
lines.push(` ${model.name} {`);
lines.push(` string id`);
lines.push(` }`);
}
}
lines.push("```");
return lines.join("\n");
}
async function main() {
const source = await fs.readFile(SCHEMA_PATH, "utf-8");
const { models, enums } = parseSchema(source);
const allModelNames = new Set(models.map((m) => m.name));
const enumNames = new Set(enums.map((e) => e.name));
const scalarTypes = new Set([
"String",
"Int",
"Float",
"Decimal",
"Boolean",
"DateTime",
"Json",
"Bytes",
"BigInt",
...enumNames,
]);
const grouped: Record<string, ParsedModel[]> = {};
for (const m of models) {
const g = groupOf(m.name);
grouped[g] ??= [];
grouped[g].push(m);
}
const out: string[] = [];
out.push("# Prisma Schema Map");
out.push("");
out.push(
"_Auto-generated by `npm run schema:map` from [prisma/schema.prisma](../prisma/schema.prisma). Do not edit by hand._",
);
out.push("");
out.push(
`Models: **${models.length}** • Enums: **${enums.length}** • Groups: **${
Object.keys(grouped).length
}**`,
);
out.push("");
out.push("## Table of contents");
out.push("");
const orderedGroups = [
...GROUP_ORDER.filter((g) => grouped[g]?.length),
...Object.keys(grouped).filter((g) => !GROUP_ORDER.includes(g)),
];
for (const g of orderedGroups) {
const slug = g.toLowerCase().replace(/[^a-z0-9]+/g, "-");
out.push(`- [${g}](#${slug}) (${grouped[g].length})`);
}
out.push("");
for (const g of orderedGroups) {
const models = grouped[g];
out.push(`## ${g}`);
out.push("");
out.push("| Model | Table | Relations | Linked to |");
out.push("| --- | --- | ---: | --- |");
for (const m of models.sort((a, b) => a.name.localeCompare(b.name))) {
const rels = relationCount(m, scalarTypes);
const targets = relationTargets(m, scalarTypes);
const targetList = targets.length
? targets
.map((t) =>
allModelNames.has(t)
? `[${t}](#${groupOf(t).toLowerCase().replace(/[^a-z0-9]+/g, "-")})`
: t,
)
.slice(0, 10)
.join(", ") + (targets.length > 10 ? ", …" : "")
: "—";
out.push(
`| **${m.name}** | \`${m.tableMap ?? "—"}\` | ${rels} | ${targetList} |`,
);
}
out.push("");
const mermaid = buildMermaid(g, models, allModelNames);
if (mermaid) {
out.push("<details><summary>ER diagram</summary>");
out.push("");
out.push(mermaid);
out.push("");
out.push("</details>");
out.push("");
}
}
if (enums.length) {
out.push("## Enums");
out.push("");
out.push("| Enum | Values |");
out.push("| --- | --- |");
for (const e of enums.sort((a, b) => a.name.localeCompare(b.name))) {
out.push(`| **${e.name}** | ${e.values.map((v) => `\`${v}\``).join(", ")} |`);
}
out.push("");
}
out.push("---");
out.push("");
out.push(
`Regenerate after schema changes: \`npm run schema:map\`. Curated groupings live in [scripts/generate-schema-map.ts](../scripts/generate-schema-map.ts) under \`MODEL_GROUPS\`.`,
);
out.push("");
await fs.mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
await fs.writeFile(OUTPUT_PATH, out.join("\n"), "utf-8");
// eslint-disable-next-line no-console
console.log(
`Wrote ${path.relative(REPO_ROOT, OUTPUT_PATH)}${models.length} models in ${
orderedGroups.length
} groups, ${enums.length} enums.`,
);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});

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

77
src/lib/flags/index.ts Normal file
View file

@ -0,0 +1,77 @@
/**
* Feature flag wrapper. Lightweight, dependency-free, env-var driven.
*
* Usage:
* import { isEnabled } from '@/lib/flags';
* if (isEnabled('bookmark_count_badge', { userId: session?.user?.id })) {
* // ...
* }
*
* Flag values are resolved 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 richer flag systems (LaunchDarkly, Statsig, Unleash, etc.) 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.
*/
type FlagContext = {
userId?: string | null;
/** Optional override — useful for tests. */
envValue?: string;
};
const KNOWN_FLAGS = new Set<string>([
// Add flags here as they're created. Helps catch typos.
// 'bookmark_count_badge',
]);
function envName(flag: string): string {
return `FLAG_${flag.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
}
function hashUserId(userId: string, salt: string): number {
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: string, ctx: FlagContext = {}): boolean {
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.ts.`);
}
}
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; // no user context, no rollout
return hashUserId(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(ctx.userId));
}
return false;
}
export function listKnownFlags(): string[] {
return [...KNOWN_FLAGS].sort();
}

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

View file

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "tests"]
}