These are the always-true rules for this repo. Depth lives in dedicated docs — read the relevant one before working:
docs/ENGINEERING.md— the quality bar (architecture, data integrity, security, UX states, a11y, performance, current Next.js 16 App Router behavior). Read it before any planning or code work.docs/PERMISSIONS.md— who may do what, and when: principals, route and action gates, the position and application state tables. Read it before planning or changing anything that gates on role, ownership or record state..claude/docs/PIPELINE.md— the automated agent pipeline and its GitHub label state machine.docs/DESIGN.md— the design system: tokens, type/spacing scale, component conventions. Read it before building or changing UI.docs/WORKFLOWS.md— what each user flow does end to end, per persona. Read it before changing a user-facing flow, and update the affected entry in the same PR.
Next.js 16 (App Router, React 19) · Prisma 7 · Tailwind CSS 4 · shadcn/ui (Radix) · TypeScript strict · zod 4 · react-hook-form · Neon/Stack Auth (lib/auth/server.ts).
- IMPORTANT: routes under
app/api/are forbidden except for the ones allowlisted here. Mutations are Server Actions; a route earns a line below only when it needs something an action cannot have, and a new route is a rule change that appends to this list.app/api/auth/[...path]/route.ts— Better Auth needs a reachable HTTP endpoint.app/api/webhooks/resend/route.ts— Resend signs the raw request body, which a server action never sees.
- Mutations are Server Actions in
prisma/actions/, each with'use server', an auth check, and zod validation. They returnvoid/ the relevant data on success,{ error }for a user-facing failure, andthrowfor unexpected ones — never{ ok }(docs/ENGINEERING.md§4). Decision test: would you show this exact sentence to the user, and can they act on it? yes →{ error }, no → throw. - Data fetching is server-side — server components call data-fetching functions in
prisma/data/; Prisma never runs in a client component. AvoiduseEffect— almost every use is a mistake here, and an empty-depsuseEffectis essentially never right. - Default to server components; add
'use client'only for interactivity/hooks/browser APIs, on the smallest leaf possible. - Every async surface ships loading + empty states; errors use one global boundary + toasts (never per-page
error.tsx); every action gives a toast (sonner) — seedocs/ENGINEERING.md§4. - Components live in
components/(ui/shadcn,forms/,layouts/,features/); route-specific components co-locate with their route. - Shared types/constants live in
lib/(lib/types.ts,lib/constants.ts) — reuse them (a little over-fetch to reuse a type is fine; never expose sensitive/internal fields to a client). Abstract repetition sensibly; avoid over-abstraction.
- Named exports only — never default exports.
- Strict TypeScript, no
any(unknown+ narrowing); prefer Prisma-generated types. - Tailwind only — avoid custom CSS. Mobile-first: base styles target mobile, add
md:/lg:upward; sidebars collapse to a Sheet/drawer on small screens; no fixed pixel widths that break narrow viewports. async/awaitover promise chains. Single-line loops/conditionals: no curly braces.- Comments are rare, one line by default, two only rarely — default to none and let naming carry it. Terse fragments, not narrated sentences; no issue/PR/
ENGINEERING §refs (git blamealready links the line to its PR). JSDoc only where the signature doesn't already say it. Full rule:docs/ENGINEERING.md§7. revalidatePath/revalidateTagafter every mutation; toast feedback (sonner) on every action.
- Commit: subject
#XXX message in lowercase imperative mood(no colon after the number, under 80 chars, no trailing period); then — only if the why isn't obvious — a blank line and a short body (wrap ~72, a few lines max; narrative belongs in the PR, not the commit); then a blank line and aCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>trailer. Commit each logical unit separately. Write the message to a file andgit commit -F .temp/commit-msg.txt— inline multi-line-m … -m …collapses on Windows, dropping the subject and the co-authorship. Delete tracked files withgit rm. The subject-line format is enforced locally by acommit-msghook (installed automatically vianpm run prepare/npm ci) and in CI byrun-commit-message-check, which validates every commit in a PR by invoking that same hook — so the two cannot drift. - Branch:
XXX-ticket-name-in-kebab-case, branched offdev. - PR: title
#XXX Ticket Name In Title Case; base branchdev(never open feature PRs againstmain); body containsCloses #XXX; assign the issue's assignee (fallback: yourself —@me); pipeline-authored PRs also carry theclaudelabel — it is what activates theapprovedmerge gate (approval-check.yml), and without it the PR merges ungated.dev → mainis promoted only by/release. - Rebase conflicts in pipeline:
revise-agentattempts autonomous resolution for structurally unambiguous conflicts (non-overlapping sections, generated files, dual independent imports). It escalates toneeds humanonly when both sides modified the same logical unit. Agents should document every resolution in the revision summary. Full protocol:.claude/docs/PIPELINE.md→ "Rebase conflict protocol".
The Neon project caps at 10 branches and 2 are permanently held (dev, production), so ~8 PRs can hold a preview database at once. Every open PR with a preview deployment consumes one slot. Reclamation is automatic (branch auto-delete on merge plus Neon's own sweep), so this is a concurrency ceiling, not a housekeeping chore — what exhausts it is too many PRs open at the same time. Exhaustion shows up as a red Vercel check on every open PR while run-prettier-check / run-linting-check / run-tsc-check stay green: check the Neon branch count before debugging Prisma. Full operational detail: .claude/docs/PIPELINE.md → "Preview-database concurrency".
npm run prettier:check # fix: npm run prettier:fix
npm run eslint:check # fix the underlying code — NEVER add eslint-disable
npm run tsc:check # fix type errors
npm run test # unit + db projects; fall back to test:unit if Postgres is unavailableNever push with known failures. Run Prisma via the npm run prisma:* scripts — npm run prisma:generate, npm run prisma:migrate -- --name <name> — never npx prisma (npx is allow-listed for shadcn only; pipeline agents auto-deny other npx). Same for prettier/tsc/tsx: use npm run …, not npx ….
All tests live under tests/, never co-located with the source they cover: tests/unit/**/*.test.ts (unit project, pure functions, no DB) and tests/db/**/*.test.ts (db project, real Postgres via tests/stubs/-aliased guards) — see vitest.config.ts and the README's "Running tests" section.
- Issues live in GitHub Issues at
SGAOperations/aplio— never Linear. Assign before starting:gh issue edit XXX --add-assignee "@me". - The pipeline's label state machine and the sub-issue / blocker linking recipes are documented in
.claude/docs/PIPELINE.mdand thescopeskill — labels are normally managed by the/pipelinecockpit; manualghlabel commands are recovery-only.
- Pipeline agents get their own isolated worktree automatically (
isolation: worktree) — they handle setup; see.claude/docs/PIPELINE.md. Do not script worktree creation for them. - Some tickets are implemented by you, not by an agent — today, anything touching
CLAUDE.mdor.claude/**, because the harness blocks a dispatched agent'sEditthere. The plan marks theseSESSION REQUIREDand the cockpit announces instead of dispatching: launch a named session (claude -n "#XXX: <short name>") and run/implement XXX, which runs stage 2/4 in its own worktree. See.claude/docs/PIPELINE.md→ "Session-required tickets". - For manual local work in a worktree, install deps with
npm ci(thennpm run prisma:generate). Do notln -s node_modules— symlinks fall back to copies on Windows here. Sync before resuming:git fetch origin && git rebase origin/dev. npm ciis what activates Git hooks — it runsprepare(husky && npm run hooks:check), which regenerates the untracked.husky/_bootstrap dir, setscore.hooksPath, and failsnpm ciitself if activation didn't take. Each worktree/clone needs its ownnpm cifor hooks to fire there. If hooks stop firing, re-runnpm ci(ornpm run prepare) and verify withnpm run hooks:check.- The same step sets
core.commentChar=';'— git's default#makes it strip the mandated#XXXsubject as a comment every time it re-reads a message through the editor machinery (git rebase --continue,git commit --amend), silently promoting the first body line into the subject; thecommit-msghook does not run on that path. Side effect: git's own instructional lines in the commit editor are;-prefixed. This writes to the shared.git/config, so one worktree'snpm cifixes every worktree of that clone. It cannot be enforced across fresh clones or forks, which is why CI validates subjects too.
Specs are brainstormed collaboratively, then the final content goes into the GitHub Issue description (gh issue edit). The Issue is the source of truth — spec files are never committed to git.