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:
parent
7aedae02fc
commit
9c1aaaa61f
34 changed files with 2909 additions and 1 deletions
133
.agent-context-manifest.yml
Normal file
133
.agent-context-manifest.yml
Normal 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
121
.convoys/README.md
Normal 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).
|
||||
|
||||
105
.cursor/agents/role-a11y-auditor.md
Normal file
105
.cursor/agents/role-a11y-auditor.md
Normal 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.
|
||||
122
.cursor/agents/role-architect.md
Normal file
122
.cursor/agents/role-architect.md
Normal 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.
|
||||
118
.cursor/agents/role-conductor.md
Normal file
118
.cursor/agents/role-conductor.md
Normal 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`.
|
||||
102
.cursor/agents/role-design-system-auditor.md
Normal file
102
.cursor/agents/role-design-system-auditor.md
Normal 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.
|
||||
83
.cursor/agents/role-doc-writer.md
Normal file
83
.cursor/agents/role-doc-writer.md
Normal 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.
|
||||
68
.cursor/agents/role-ia-architect.md
Normal file
68
.cursor/agents/role-ia-architect.md
Normal 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.
|
||||
99
.cursor/agents/role-implementer.md
Normal file
99
.cursor/agents/role-implementer.md
Normal 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.
|
||||
101
.cursor/agents/role-reviewer.md
Normal file
101
.cursor/agents/role-reviewer.md
Normal 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.
|
||||
68
.cursor/agents/role-ux-reviewer.md
Normal file
68
.cursor/agents/role-ux-reviewer.md
Normal 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.
|
||||
98
.cursor/rules/api-routes.mdc
Normal file
98
.cursor/rules/api-routes.mdc
Normal 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`).
|
||||
39
.cursor/rules/no-go-zones.mdc
Normal file
39
.cursor/rules/no-go-zones.mdc
Normal 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.
|
||||
35
.cursor/rules/prisma-schema-map.mdc
Normal file
35
.cursor/rules/prisma-schema-map.mdc
Normal 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
74
.cursor/rules/prisma.mdc
Normal 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.
|
||||
156
.cursor/skills/add-api-route/SKILL.md
Normal file
156
.cursor/skills/add-api-route/SKILL.md
Normal 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`.
|
||||
113
.cursor/skills/add-prisma-model/SKILL.md
Normal file
113
.cursor/skills/add-prisma-model/SKILL.md
Normal 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
30
.github/CODEOWNERS
vendored
Normal 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
44
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
98
.github/workflows/ci.yml
vendored
Normal 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
93
.github/workflows/pr-health-rollup.yml
vendored
Normal 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
79
.github/workflows/preview-smoke.yml
vendored
Normal 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
76
.github/workflows/visual-diff.yml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -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
68
AGENTS.md
Normal 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
206
docs/SCHEMA_MAP.md
Normal 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`.
|
||||
49
docs/agent-context/README.md
Normal file
49
docs/agent-context/README.md
Normal 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).
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
291
scripts/generate-schema-map.ts
Normal file
291
scripts/generate-schema-map.ts
Normal 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
89
scripts/log-convoy-event.sh
Executable 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
37
scripts/wt.sh
Executable 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
77
src/lib/flags/index.ts
Normal 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();
|
||||
}
|
||||
32
tests/smoke/app.smoke.spec.ts
Normal file
32
tests/smoke/app.smoke.spec.ts
Normal 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).
|
||||
|
|
@ -30,5 +30,5 @@
|
|||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "tests"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue