From 840374bd85e4eae552fc1fec8f1b45ecc31cac41 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 24 Apr 2026 10:58:31 -0400
Subject: [PATCH 1/2] feat: add risk scores in main overview page and make risk
matrix selected value more clear
[dev] [Marfuen] mariano/sale-46-add-basic-risk-score
---
.../components/VendorsTable.test.tsx | 39 ++++++++++++++
.../(overview)/components/VendorsTable.tsx | 47 +++++++++++++----
.../src/components/risks/RiskScoreBadge.tsx | 42 +++++++++++++++
.../risks/charts/RiskMatrixChart.tsx | 27 ++--------
apps/app/src/lib/risk-score.test.ts | 51 +++++++++++++++++++
apps/app/src/lib/risk-score.ts | 39 ++++++++++++++
6 files changed, 211 insertions(+), 34 deletions(-)
create mode 100644 apps/app/src/components/risks/RiskScoreBadge.tsx
create mode 100644 apps/app/src/lib/risk-score.test.ts
create mode 100644 apps/app/src/lib/risk-score.ts
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx
index a38be263d1..05baa58be4 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx
@@ -239,4 +239,43 @@ describe('VendorsTable', () => {
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
expect(screen.getByText('Cloud')).toBeInTheDocument();
});
+
+ it('renders the INHERENT RISK column with a numeric score for assessed vendors', () => {
+ setMockPermissions({});
+
+ render(
+ ,
+ );
+
+ // Column header
+ expect(screen.getByText('INHERENT RISK')).toBeInTheDocument();
+ // Acme Corp (possible × moderate) → raw 9 → score 4/10
+ expect(screen.getByText('4/10')).toBeInTheDocument();
+ });
+
+ it('shows an em-dash for vendors that have not been assessed', () => {
+ setMockPermissions({});
+
+ const notAssessedVendor = {
+ ...mockVendors[0],
+ id: 'vendor-2',
+ name: 'Pending Inc',
+ status: 'not_assessed',
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('—')).toBeInTheDocument();
+ expect(screen.queryByText('1/10')).not.toBeInTheDocument();
+ });
});
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
index e0bf11e674..376b6b731a 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
@@ -1,9 +1,11 @@
'use client';
import { OnboardingLoadingAnimation } from '@/components/onboarding-loading-animation';
+import { RiskScoreBadge } from '@/components/risks/RiskScoreBadge';
+import { VendorStatus } from '@/components/vendor-status';
import { usePermissions } from '@/hooks/use-permissions';
import { useVendors, useVendorActions, type Vendor } from '@/hooks/use-vendors';
-import { VendorStatus } from '@/components/vendor-status';
+import { getRiskScore } from '@/lib/risk-score';
import {
AlertDialog,
AlertDialogAction,
@@ -168,7 +170,10 @@ export function VendorsTable({
// Local state for search, sorting, and pagination
const [searchQuery, setSearchQuery] = useState('');
- const [sort, setSort] = useState<{ id: 'name' | 'updatedAt'; desc: boolean }>({
+ const [sort, setSort] = useState<{
+ id: 'name' | 'updatedAt' | 'inherentRisk';
+ desc: boolean;
+ }>({
id: 'name',
desc: false,
});
@@ -319,15 +324,17 @@ export function VendorsTable({
// Sort
result.sort((a, b) => {
- const aValue = sort.id === 'name' ? a.name : a.updatedAt;
- const bValue = sort.id === 'name' ? b.name : b.updatedAt;
-
if (sort.id === 'name') {
- const comparison = (aValue as string).localeCompare(bValue as string);
+ const comparison = a.name.localeCompare(b.name);
+ return sort.desc ? -comparison : comparison;
+ }
+ if (sort.id === 'inherentRisk') {
+ const aScore = getRiskScore(a.inherentProbability, a.inherentImpact).raw;
+ const bScore = getRiskScore(b.inherentProbability, b.inherentImpact).raw;
+ const comparison = aScore - bScore;
return sort.desc ? -comparison : comparison;
}
- const comparison =
- new Date(aValue as string).getTime() - new Date(bValue as string).getTime();
+ const comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
return sort.desc ? -comparison : comparison;
});
@@ -385,7 +392,7 @@ export function VendorsTable({
router.push(`/${orgId}/vendors/${vendorId}`);
};
- const handleSort = (columnId: 'name' | 'updatedAt') => {
+ const handleSort = (columnId: 'name' | 'updatedAt' | 'inherentRisk') => {
if (sort.id === columnId) {
setSort({ id: columnId, desc: !sort.desc });
} else {
@@ -393,7 +400,7 @@ export function VendorsTable({
}
};
- const getSortIcon = (columnId: 'name' | 'updatedAt') => {
+ const getSortIcon = (columnId: 'name' | 'updatedAt' | 'inherentRisk') => {
if (sort.id !== columnId) {
return ;
}
@@ -528,6 +535,16 @@ export function VendorsTable({
STATUS
+
+
+ CATEGORYOWNER
{hasPermission('vendor', 'delete') && ACTIONS}
@@ -549,6 +566,16 @@ export function VendorsTable({
+
+ {vendor.status === 'not_assessed' ? (
+ —
+ ) : (
+
+ )}
+
{CATEGORY_MAP[vendor.category] || vendor.category}
diff --git a/apps/app/src/components/risks/RiskScoreBadge.tsx b/apps/app/src/components/risks/RiskScoreBadge.tsx
new file mode 100644
index 0000000000..5cb0f52811
--- /dev/null
+++ b/apps/app/src/components/risks/RiskScoreBadge.tsx
@@ -0,0 +1,42 @@
+import { cn } from '@/lib/utils';
+import type { Impact, Likelihood } from '@db';
+import { getRiskScore, type RiskLevel } from '@/lib/risk-score';
+
+const LEVEL_CLASSES: Record = {
+ 'very-low':
+ 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
+ low: 'bg-green-500/15 border-green-500/40 text-green-700 dark:text-green-300',
+ medium: 'bg-yellow-500/15 border-yellow-600/40 text-yellow-700 dark:text-yellow-300',
+ high: 'bg-orange-500/15 border-orange-500/40 text-orange-700 dark:text-orange-300',
+ 'very-high': 'bg-red-500/15 border-red-500/40 text-red-700 dark:text-red-300',
+};
+
+const LEVEL_LABEL: Record = {
+ 'very-low': 'Very low',
+ low: 'Low',
+ medium: 'Medium',
+ high: 'High',
+ 'very-high': 'Very high',
+};
+
+export interface RiskScoreBadgeProps {
+ likelihood: Likelihood;
+ impact: Impact;
+ className?: string;
+}
+
+export function RiskScoreBadge({ likelihood, impact, className }: RiskScoreBadgeProps) {
+ const { score, level } = getRiskScore(likelihood, impact);
+ return (
+
+ {score}/10
+
+ );
+}
diff --git a/apps/app/src/components/risks/charts/RiskMatrixChart.tsx b/apps/app/src/components/risks/charts/RiskMatrixChart.tsx
index 923b74d5f9..7853115fd3 100644
--- a/apps/app/src/components/risks/charts/RiskMatrixChart.tsx
+++ b/apps/app/src/components/risks/charts/RiskMatrixChart.tsx
@@ -1,25 +1,10 @@
'use client';
+import { IMPACT_SCORES, LIKELIHOOD_SCORES, getRiskLevel } from '@/lib/risk-score';
import { Impact, Likelihood } from '@db';
import { Button, HStack, Section } from '@trycompai/design-system';
import { useEffect, useState } from 'react';
-const LIKELIHOOD_SCORES: Record = {
- very_unlikely: 1,
- unlikely: 2,
- possible: 3,
- likely: 4,
- very_likely: 5,
-};
-
-const IMPACT_SCORES: Record = {
- insignificant: 1,
- minor: 2,
- moderate: 3,
- major: 4,
- severe: 5,
-};
-
const VISUAL_LIKELIHOOD_ORDER: Likelihood[] = [
Likelihood.very_likely,
Likelihood.likely,
@@ -114,13 +99,7 @@ export function RiskMatrixChart({
const likelihoodScore =
LIKELIHOOD_SCORES[VISUAL_LIKELIHOOD_ORDER[probabilityLevels.indexOf(probability)]];
const impactScore = IMPACT_SCORES[VISUAL_IMPACT_ORDER[impactLevels.indexOf(impact)]];
- const score = likelihoodScore * impactScore;
-
- let level: RiskCell['level'] = 'very-low';
- if (score > 16) level = 'very-high';
- else if (score > 9) level = 'high';
- else if (score > 4) level = 'medium';
- else if (score > 1) level = 'low';
+ const level = getRiskLevel(likelihoodScore * impactScore);
return {
probability,
@@ -207,7 +186,7 @@ export function RiskMatrixChart({
onClick={() => handleCellClick(probability, impact)}
>
{cell?.value && (
-
+
)}
);
diff --git a/apps/app/src/lib/risk-score.test.ts b/apps/app/src/lib/risk-score.test.ts
new file mode 100644
index 0000000000..70b1a951c7
--- /dev/null
+++ b/apps/app/src/lib/risk-score.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import { getRiskLevel, getRiskScore } from './risk-score';
+
+describe('getRiskScore', () => {
+ it('returns 1/10 very-low at the minimum corner (1×1)', () => {
+ expect(getRiskScore('very_unlikely', 'insignificant')).toEqual({
+ raw: 1,
+ score: 1,
+ level: 'very-low',
+ });
+ });
+
+ it('returns 10/10 very-high at the maximum corner (5×5)', () => {
+ expect(getRiskScore('very_likely', 'severe')).toEqual({
+ raw: 25,
+ score: 10,
+ level: 'very-high',
+ });
+ });
+
+ it('computes raw as likelihood × impact', () => {
+ expect(getRiskScore('possible', 'moderate').raw).toBe(9);
+ expect(getRiskScore('likely', 'major').raw).toBe(16);
+ expect(getRiskScore('very_likely', 'major').raw).toBe(20);
+ });
+
+ it('normalizes raw 1–25 into a 1–10 integer score', () => {
+ for (const l of ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'] as const) {
+ for (const i of ['insignificant', 'minor', 'moderate', 'major', 'severe'] as const) {
+ const { score } = getRiskScore(l, i);
+ expect(score).toBeGreaterThanOrEqual(1);
+ expect(score).toBeLessThanOrEqual(10);
+ expect(Number.isInteger(score)).toBe(true);
+ }
+ }
+ });
+});
+
+describe('getRiskLevel', () => {
+ it('matches the risk-matrix thresholds used elsewhere', () => {
+ expect(getRiskLevel(1)).toBe('very-low');
+ expect(getRiskLevel(2)).toBe('low');
+ expect(getRiskLevel(4)).toBe('low');
+ expect(getRiskLevel(5)).toBe('medium');
+ expect(getRiskLevel(9)).toBe('medium');
+ expect(getRiskLevel(10)).toBe('high');
+ expect(getRiskLevel(16)).toBe('high');
+ expect(getRiskLevel(17)).toBe('very-high');
+ expect(getRiskLevel(25)).toBe('very-high');
+ });
+});
diff --git a/apps/app/src/lib/risk-score.ts b/apps/app/src/lib/risk-score.ts
new file mode 100644
index 0000000000..7aa10a4f06
--- /dev/null
+++ b/apps/app/src/lib/risk-score.ts
@@ -0,0 +1,39 @@
+import { Impact, Likelihood } from '@db';
+
+export const LIKELIHOOD_SCORES: Record = {
+ very_unlikely: 1,
+ unlikely: 2,
+ possible: 3,
+ likely: 4,
+ very_likely: 5,
+};
+
+export const IMPACT_SCORES: Record = {
+ insignificant: 1,
+ minor: 2,
+ moderate: 3,
+ major: 4,
+ severe: 5,
+};
+
+export type RiskLevel = 'very-low' | 'low' | 'medium' | 'high' | 'very-high';
+
+export interface RiskScore {
+ raw: number;
+ score: number;
+ level: RiskLevel;
+}
+
+export function getRiskLevel(raw: number): RiskLevel {
+ if (raw > 16) return 'very-high';
+ if (raw > 9) return 'high';
+ if (raw > 4) return 'medium';
+ if (raw > 1) return 'low';
+ return 'very-low';
+}
+
+export function getRiskScore(likelihood: Likelihood, impact: Impact): RiskScore {
+ const raw = LIKELIHOOD_SCORES[likelihood] * IMPACT_SCORES[impact];
+ const score = Math.max(1, Math.ceil(raw / 2.5));
+ return { raw, score, level: getRiskLevel(raw) };
+}
From c75c39d4e98d6867e13a591c3797d261d27a202a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 24 Apr 2026 11:34:32 -0400
Subject: [PATCH 2/2] chore(worktrees): auto-link .env files on git worktree
add
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore(worktrees): auto-link .env files on git worktree add
Add a shared .githooks/ directory with a post-checkout hook that
symlinks every .env* from the main worktree into each freshly created
worktree. Scoped via two layered checks — prev-HEAD is the null SHA
AND the current worktree isn't the main one — so it fires exclusively
inside `git worktree add`, never on regular branch/file checkouts or
fresh clones.
Enable with a one-time `git config core.hooksPath .githooks` per clone.
Since all worktrees share a single .git/, the config persists for all
current and future worktrees automatically.
The hook delegates to scripts/link-worktree-envs.sh so the same logic
backfills worktrees that existed before the hook was installed.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(worktrees): run bun install + db:generate + build on new worktree
After the post-checkout hook symlinks .env files into a freshly created
worktree, it now also invokes scripts/setup-worktree.sh which runs
`bun install`, `bun run db:generate`, and `bun run build` so the
worktree is usable immediately.
Runs synchronously on purpose — callers (especially Claude Code) tend
to start executing in the worktree right after creation, and we don't
want them racing ahead of the install. Skippable via
`SKIP_WORKTREE_SETUP=1 git worktree add …` for a fast "just give me the
files" worktree.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(worktrees): make bun run build opt-in during auto-setup
`bun run build` adds several minutes per worktree and is unnecessary
for the common path (dev server, tests, typecheck). Default the
setup script to install + db:generate only, and gate the build step
behind SETUP_WORKTREE_WITH_BUILD=1 for the rare case it's needed.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(worktrees): run prisma migrate before db:generate in setup
After `bun install` and before `bun run db:generate`, run
`cd packages/db && bun run db:migrate` so any new migrations on the
branch are applied to the local DB before the clients are regenerated.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(worktrees): isolate DB per worktree to prevent cross-branch drift
Add scripts/setup-worktree-db.sh that creates compdev_ for each
new worktree using the same Postgres host/credentials as main, and
have scripts/link-worktree-envs.sh copy-and-rewrite .env files
containing DATABASE_URL so each worktree's migrations hit its own DB.
Env files without DATABASE_URL stay as symlinks so API keys and such
still auto-propagate.
Uses the `pg` node module from the main worktree's node_modules (via a
small scripts/create-database.mjs helper) so psql/libpq isn't required
on the host.
Skip via SKIP_WORKTREE_DB=1 git worktree add … to opt back into the
shared DB (the previous behavior).
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(skills): add stale-worktree-cleanup skill
Defines a safe process for reaping old worktrees together with their
isolated compdev_* databases. Since git has no pre-worktree-remove
hook, dead databases accumulate silently — this skill gives Claude
(or a human) a classify-then-confirm-then-remove workflow so nothing
in-flight gets lost.
Includes inventory via `gh pr list`, per-worktree dirty/unpushed
checks, and safety rules against --force without consent, dropping
non-compdev_* databases, or touching the main worktree.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(worktrees): add dev:no-trigger scripts + single-active-worktree rule
Trigger.dev's `trigger dev` CLI has no per-branch or per-session
isolation — env is hardcoded to the project's shared "dev"
(verified in node_modules/trigger.dev/dist/esm/commands/dev.js).
Running `bun run dev` in multiple worktrees causes last-writer-wins
task registration and zombie workers.
Add dev:no-trigger scripts to apps/app and apps/api (UI-only, no
trigger dev) so only one active worktree runs the full stack at a
time. Document the rule in .githooks/README.md.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(skills): add new-feature-setup skill documenting the worktree-based workflow
When starting any new feature/ticket/branch, this skill points to the
existing hook-driven auto-setup (compdev_ DB, env linking,
install+migrate+generate) and the single-active-trigger-dev rule so
new Claude sessions don't reinvent env copying, database bootstrapping,
or fight `bun install` by hand in every worktree.
Companion to stale-worktree-cleanup for the other end of the lifecycle.
Co-Authored-By: Claude Opus 4.7 (1M context)
---------
Co-authored-by: Mariano
Co-authored-by: Claude Opus 4.7 (1M context)
---
.claude/skills/new-feature-setup/SKILL.md | 114 ++++++++++++
.../skills/stale-worktree-cleanup/SKILL.md | 174 ++++++++++++++++++
.githooks/README.md | 130 +++++++++++++
.githooks/post-checkout | 53 ++++++
apps/api/package.json | 1 +
apps/app/package.json | 1 +
scripts/create-database.mjs | 33 ++++
scripts/link-worktree-envs.sh | 75 ++++++++
scripts/setup-worktree-db.sh | 72 ++++++++
scripts/setup-worktree.sh | 50 +++++
10 files changed, 703 insertions(+)
create mode 100644 .claude/skills/new-feature-setup/SKILL.md
create mode 100644 .claude/skills/stale-worktree-cleanup/SKILL.md
create mode 100644 .githooks/README.md
create mode 100755 .githooks/post-checkout
create mode 100644 scripts/create-database.mjs
create mode 100755 scripts/link-worktree-envs.sh
create mode 100755 scripts/setup-worktree-db.sh
create mode 100755 scripts/setup-worktree.sh
diff --git a/.claude/skills/new-feature-setup/SKILL.md b/.claude/skills/new-feature-setup/SKILL.md
new file mode 100644
index 0000000000..79f281de21
--- /dev/null
+++ b/.claude/skills/new-feature-setup/SKILL.md
@@ -0,0 +1,114 @@
+---
+name: new-feature-setup
+description: Use when starting a new feature, Linear ticket, or bugfix in this repo — establishes the branch + worktree + env + DB + dev-server conventions so the work is immediately ready to code without fighting infra. Triggers on "start a new feature", "spin up a worktree", "begin ticket", "new branch".
+---
+
+# New Feature Setup (comp monorepo)
+
+## Overview
+
+This repo has a lot of infrastructure pre-wired into `git worktree add`. Use it. Don't reinvent env copying, database setup, or dependency install flows in every new session.
+
+## When to Use
+
+- User says "start a new feature", "spin up a worktree for X", "begin this Linear ticket", "new branch for …"
+- Before running `bun install` / `bun run db:generate` manually in a new directory
+- Before copying `.env` files around by hand
+
+## When NOT to Use
+
+- User is editing an existing worktree (already set up)
+- Infra repair / debugging of the hook itself (read `.githooks/README.md` instead)
+
+## Workflow
+
+### 1. Create the worktree from `origin/main`
+
+```sh
+cd /Users/mariano/code/comp # must be the MAIN clone, not another worktree
+git fetch origin main
+git worktree add .worktrees/ -b origin/main
+```
+
+- For Linear tickets, use Linear's suggested branch name (`mariano/`).
+- For infra / chore work, use `mariano/` or `chore/`.
+- The `` on the worktree path should match the branch's suffix (it becomes the Postgres DB slug after `tr '-' '_'`).
+
+### 2. Let the hook do everything else
+
+`git worktree add` fires the `post-checkout` hook at `.githooks/post-checkout`, which runs synchronously:
+
+1. Creates `compdev_` Postgres database (isolated per worktree)
+2. Links `.env*` files from the main clone (copies the ones with `DATABASE_URL`, rewriting it to the isolated URL; symlinks the rest so API keys auto-propagate)
+3. Runs `bun install`, applies Prisma migrations, regenerates clients
+
+**Do not** run any of these by hand. If the hook logs a failure, diagnose and fix at the source — don't paper over with a manual install.
+
+Skip toggles (rare):
+- `SKIP_WORKTREE_DB=1` — share the main `comp` DB (drift risk; only for read-only worktrees)
+- `SKIP_WORKTREE_SETUP=1` — skip install + migrate + generate (for a "just files" worktree)
+- `SETUP_WORKTREE_WITH_BUILD=1` — also run `bun run build` (adds minutes; only when you need the built artifacts)
+
+### 3. Start the dev server — coordinate with the "active worktree" rule
+
+Trigger.dev's `trigger dev` CLI **cannot** be isolated per worktree. Running `bun run dev` in multiple worktrees stomps on task registration.
+
+- **One active worktree** runs `bun run dev` (full stack with `trigger dev`).
+- **All other worktrees** run:
+ ```sh
+ bun run --filter '@trycompai/app' dev:no-trigger # Next.js only
+ bun run --filter '@trycompai/api' dev:no-trigger # NestJS only
+ ```
+- Non-active worktrees need a different `PORT` to avoid collision — add `PORT=3001` (or `3334`, etc.) to the worktree's `.env.local`. `.env.local` is not symlinked and stays per-worktree.
+- When swapping which worktree is active, kill the old full `bun run dev` first so task registration is clean.
+
+### 4. Code the feature
+
+Standard repo conventions apply (see `CLAUDE.md`). Highlights:
+- TDD for any non-trivial change (`superpowers:test-driven-development`)
+- Brainstorm before building new UX (`superpowers:brainstorming`)
+- Plans + subagent-driven execution for multi-step work
+- Run `audit-design-system` after any frontend component edit
+- Always run typecheck before declaring a change done (`npx turbo run typecheck --filter=`)
+
+### 5. When done, clean up
+
+Use the `stale-worktree-cleanup` skill when worktrees accumulate. It handles both `git worktree remove` and dropping the `compdev_` database in one pass. Never leave orphan databases — they pile up silently because git has no `pre-worktree-remove` hook.
+
+## Quick Reference
+
+```sh
+# Spin up a new worktree (does env + DB + install + migrate + generate automatically)
+git worktree add .worktrees/ -b mariano/ origin/main
+
+# Start dev — ONLY in the worktree you're actively iterating on
+cd .worktrees/
+bun run dev
+
+# Start dev in a background worktree (no trigger dev, custom port via .env.local)
+echo "PORT=3001" >> apps/app/.env.local
+bun run --filter '@trycompai/app' dev:no-trigger
+
+# Clean up when branch is done
+# (use the stale-worktree-cleanup skill)
+```
+
+## Red Flags
+
+If you catch yourself doing any of these, stop — the hook should have handled it:
+
+- Running `bun install` manually in a new worktree
+- `cp` or `ln -s` to copy `.env` files into a worktree
+- Writing a script that "creates a database for my branch"
+- Running `bun run db:migrate` in a worktree right after creating it
+- Ignoring a failing `bun run dev` in two worktrees instead of swapping to `dev:no-trigger`
+
+## Common Mistakes
+
+| Mistake | Fix |
+|---|---|
+| Creating the worktree from another worktree instead of the main clone | Always `cd` to `/Users/mariano/code/comp` first |
+| Editing `.env` in a worktree and expecting it to propagate | If it's a symlink, yes; if it's a real copy (has `DATABASE_URL`), no. Check with `ls -la`. |
+| Forgetting to bump `PORT` → two dev servers collide | Put `PORT=` in the worktree's `.env.local` |
+| Running `trigger dev` in multiple worktrees | Switch to `dev:no-trigger` in all but one |
+| Not cleaning up → orphan `compdev_*` databases piling up | Use the `stale-worktree-cleanup` skill regularly |
diff --git a/.claude/skills/stale-worktree-cleanup/SKILL.md b/.claude/skills/stale-worktree-cleanup/SKILL.md
new file mode 100644
index 0000000000..dcd19860f1
--- /dev/null
+++ b/.claude/skills/stale-worktree-cleanup/SKILL.md
@@ -0,0 +1,174 @@
+---
+name: stale-worktree-cleanup
+description: Use when cleaning up old git worktrees, removing worktrees whose branches have merged or been abandoned, or dropping orphaned compdev_* Postgres databases. Triggers on "clean up worktrees", "delete stale worktrees", "worktrees piling up", "orphaned databases", "remove unused worktree".
+---
+
+# Stale Worktree Cleanup
+
+## Overview
+
+This repo's `.githooks/post-checkout` creates a per-worktree Postgres database (`compdev_`) on every `git worktree add`. Git has no `pre-worktree-remove` hook, so dead databases pile up over time. This skill defines a safe, reversible process to reap both the worktree directories and their dangling databases together.
+
+**Core principle**: never delete work the user might still want. Classify first, ask second, remove last.
+
+## When to Use
+
+- User says "clean up worktrees", "delete stale worktrees", "remove old worktrees", "worktrees piling up"
+- User mentions orphaned `compdev_*` DBs or running out of disk space from dead `node_modules`
+- Starting a new feature and noticing many old worktree dirs
+
+## When NOT to Use
+
+- User wants to remove ONE specific worktree (just run `git worktree remove ` + drop its DB directly — don't load the whole process)
+- User is actively working in a worktree (never touch active work)
+
+## Process
+
+### Step 1 — Inventory
+
+Run these four commands (parallel-safe) and capture the output:
+
+```sh
+git worktree list --porcelain
+gh pr list --author @me --state all --limit 50 --json headRefName,state,url,number
+git branch --no-color | cat
+```
+
+Then query the DB for `compdev_*` databases. The management URL is the main worktree's `DATABASE_URL` with the database path swapped to `postgres`:
+
+```sh
+bun -e '
+ import { Client } from "pg";
+ const raw = require("fs").readFileSync("packages/db/.env", "utf8");
+ const url = raw.match(/^DATABASE_URL=(.*)$/m)[1].replace(/^["\x27]|["\x27]$/g, "");
+ const mgmt = url.replace(/\/[^/?]+(\?|$)/, "/postgres$1");
+ const c = new Client({ connectionString: mgmt });
+ await c.connect();
+ const r = await c.query("SELECT datname FROM pg_database WHERE datname LIKE \x27compdev\\\\_%\x27 ORDER BY 1");
+ for (const row of r.rows) console.log(row.datname);
+ await c.end();
+'
+```
+
+### Step 2 — Classify each worktree
+
+For each worktree (skip the main one):
+
+| Signal | Classification |
+|---|---|
+| Branch merged to main AND clean working tree AND no unpushed commits | **safe** |
+| PR is `CLOSED` (not merged) | **needs-confirm** |
+| Uncommitted changes OR unpushed commits | **needs-confirm** |
+| No matching PR, no merge, has local commits | **keep** (user may still be working on it) |
+| Is the main worktree | **skip** |
+
+Gather per worktree:
+- `cd && git status --porcelain | wc -l` — uncommitted changes count
+- `cd && git log @{upstream}..HEAD --oneline 2>/dev/null | wc -l` — unpushed commits (0 if no upstream)
+- Branch → PR lookup from step 1
+
+### Step 3 — Present to user
+
+Show a table and explicit recommendations. Example:
+
+```
+Path Branch PR state Changes Recommendation
+.worktrees/sale-45-… mariano/sale-45-… MERGED 0 / 0 ✅ safe to remove
+.worktrees/old-experiment mariano/scratch — 3 / 0 ⚠ uncommitted — confirm first
+.worktrees/worktree-env-auto-link mariano/worktree-env-auto-link OPEN 0 / 0 ⏳ keep (PR open)
+
+Orphan databases (no worktree dir):
+ compdev_abandoned_feature
+ compdev_old_migration_test
+```
+
+Then ask: **"Remove the items marked ✅? Confirm by listing anything you want me to also nuke from the ⚠ / ⏳ set."**
+
+### Step 4 — Remove confirmed items
+
+For each worktree the user approved:
+
+```sh
+# 1. Remove the worktree dir (use --force only if user confirmed dirty-removal)
+git worktree remove "" # clean case
+git worktree remove --force "" # only after explicit user OK
+
+# 2. Derive the slug and drop the database
+slug=$(basename "" | tr '[:upper:]' '[:lower:]' | tr '-' '_' | tr -cd 'a-z0-9_')
+bun -e '
+ import { Client } from "pg";
+ const raw = require("fs").readFileSync("packages/db/.env", "utf8");
+ const url = raw.match(/^DATABASE_URL=(.*)$/m)[1].replace(/^["\x27]|["\x27]$/g, "");
+ const mgmt = url.replace(/\/[^/?]+(\?|$)/, "/postgres$1");
+ const c = new Client({ connectionString: mgmt });
+ await c.connect();
+ await c.query(`DROP DATABASE IF EXISTS "compdev_'"$slug"'"`);
+ await c.end();
+ console.log("dropped compdev_'"$slug"'");
+'
+```
+
+For orphan databases (no matching worktree dir), just drop them — no worktree to remove.
+
+**Do NOT** delete the local branch unless the user explicitly asked. Branches can be recreated from `origin` cheaply; worktrees cannot.
+
+### Step 5 — Verify and report
+
+```sh
+git worktree list
+# then re-run the compdev_* query from step 1
+```
+
+Report back:
+- Worktrees removed (paths)
+- Databases dropped (names)
+- Anything skipped and why
+- What's left (still-active worktrees)
+
+## Safety Rules
+
+- **Never remove the main worktree.** Its path is always the first line of `git worktree list --porcelain`.
+- **Never `--force` without confirmation.** Dirty worktrees can contain un-stashed work.
+- **Never `DROP DATABASE` on anything not matching `^compdev_[a-z0-9_]+$`.** Never drop `comp`, `postgres`, or any production-looking name.
+- **Never delete a local branch** as part of cleanup unless the user explicitly asks. Orphaned branches are cheap; lost work isn't.
+- **Never run this inside a worktree you're about to delete.** `cd` to the main worktree first.
+
+## Common Mistakes
+
+| Mistake | Fix |
+|---|---|
+| Dropping the DB but leaving the worktree dir | Run `git worktree prune` then `git worktree remove` |
+| Removing the worktree but leaving the DB (accumulates orphans) | Always do both in the same pass |
+| Using hyphens in the DB name | The hook slug rule is `tr '-' '_'` — always underscores |
+| Running from inside a doomed worktree | `cd` to the main worktree before starting the process |
+| Using `gh pr list` on a branch with no PR | Missing data is not "abandoned" — needs `needs-confirm` classification |
+
+## Red Flags
+
+If any of these show up mid-process, **stop and ask the user**:
+
+- A worktree has unpushed commits AND no PR → might be unreleased work
+- The classification returned >10 "safe to remove" items → unusual volume, double-check
+- `git worktree remove` errors with "working tree is not clean" → never retry with `--force` without explicit consent
+- A `compdev_*` name has weird characters or unexpected format → don't drop
+
+## Quick Reference
+
+```sh
+# List everything
+git worktree list --porcelain
+gh pr list --author @me --state all --limit 50 --json headRefName,state
+
+# Per-worktree inspection
+git -C status --porcelain
+git -C log @{upstream}..HEAD --oneline 2>/dev/null
+
+# Clean removal
+git worktree remove
+
+# Dirty removal (requires user confirmation first)
+git worktree remove --force
+
+# Database drop (run from main worktree)
+# See the bun -e snippets above for the exact invocation
+```
diff --git a/.githooks/README.md b/.githooks/README.md
new file mode 100644
index 0000000000..89f8007d8a
--- /dev/null
+++ b/.githooks/README.md
@@ -0,0 +1,130 @@
+# Git hooks
+
+Shared, repo-tracked git hooks for this project.
+
+## Setup (once per clone)
+
+```sh
+git config core.hooksPath .githooks
+```
+
+All worktrees share a single `.git/` directory with the main clone, so this
+one `git config` call enables the hooks for every current and future
+worktree — no need to re-run after `git worktree add`.
+
+## What's here
+
+### `post-checkout`
+
+Runs three steps automatically on `git worktree add`:
+
+1. **Create an isolated Postgres database** for this worktree
+ (`scripts/setup-worktree-db.sh`). Connects to the Postgres instance
+ from the main worktree's `packages/db/.env` and creates
+ `compdev_` if it doesn't already exist. The slug is derived
+ from the worktree's directory name (hyphens → underscores,
+ lowercase). Returns the isolated `DATABASE_URL`.
+2. **Set up `.env*` files** in the new worktree
+ (`scripts/link-worktree-envs.sh`). Files without a `DATABASE_URL`
+ line are symlinked to the main worktree so shared secrets
+ auto-propagate. Files with `DATABASE_URL` get a real copy with
+ `DATABASE_URL` (and `DIRECT_URL`) rewritten to the isolated URL —
+ this way each worktree uses its own DB while still sharing API
+ keys, etc.
+3. **Install deps + apply migrations + generate clients** —
+ `bun install`, `cd packages/db && bun run db:migrate`, then
+ `bun run db:generate` (`scripts/setup-worktree.sh`). The full
+ `bun run build` is opt-in because it adds several minutes and most
+ dev workflows (`dev`, tests, typechecks) don't need it.
+
+The hook fires **only** inside `git worktree add` — regular `git checkout`,
+`git switch`, and file checkouts are filtered by checking that the
+previous HEAD is the null SHA (true only for fresh checkouts) and that
+the current worktree isn't the main one.
+
+Step 2 is synchronous on purpose: callers — including Claude Code —
+typically start running commands in the new worktree immediately, and we
+don't want them racing ahead of the install.
+
+Toggles:
+- `SKIP_WORKTREE_DB=1 git worktree add …` — skip isolated DB creation
+ and `DATABASE_URL` rewriting. The worktree uses the shared `comp` DB
+ (same behavior as before DB isolation was added).
+- `SKIP_WORKTREE_SETUP=1 git worktree add …` — skip install + generate
+ (just link envs + create the DB).
+- `SETUP_WORKTREE_WITH_BUILD=1 git worktree add …` — also run
+ `bun run build` when you actually need the built artifacts.
+
+## Backfilling existing worktrees
+
+For worktrees created before the hook was installed:
+
+```sh
+# From inside the worktree that needs envs linked:
+scripts/link-worktree-envs.sh
+
+# Or pass an explicit path from anywhere:
+scripts/link-worktree-envs.sh /path/to/.worktrees/some-feature
+
+# And/or run the full install + build:
+scripts/setup-worktree.sh /path/to/.worktrees/some-feature
+```
+
+## Only one worktree runs `trigger dev` at a time
+
+Trigger.dev's dev CLI has no per-branch or per-session isolation —
+it's hardcoded to connect to the project's single shared "dev"
+environment (verified by reading the CLI source in
+`node_modules/trigger.dev/dist/esm/commands/dev.js`). Running
+`bun run dev` in five worktrees → five processes register tasks
+against the same environment, last-writer-wins, zombie workers,
+tasks cross-executed on the wrong branch's code.
+
+The rule: **pick one "active" worktree** for `bun run dev` (full
+stack with `trigger dev`). In the other worktrees, run the UI
+framework only:
+
+```sh
+# apps/app — Next.js only, no trigger dev
+bun run --filter '@trycompai/app' dev:no-trigger
+
+# apps/api — NestJS only, no trigger dev
+bun run --filter '@trycompai/api' dev:no-trigger
+```
+
+Port collisions across worktrees are still your problem: bump the
+`PORT` in the worktree's `.env.local` (e.g. `PORT=3001`, `PORT=3334`)
+for any non-default worktree.
+
+When you swap which worktree is active, kill the full `bun run dev`
+in the old one first so task registration is clean before the new
+one claims the dev environment.
+
+## Per-worktree overrides
+
+Env files without `DATABASE_URL` are still symlinks — editing them
+mutates the shared file in the main worktree. Files with `DATABASE_URL`
+are real copies taken at worktree creation time; if you add a new env
+var to a main-worktree `.env` that you want to see in existing
+worktrees, re-run
+`ISOLATED_DATABASE_URL= scripts/link-worktree-envs.sh ` to
+regenerate (or delete the file in the worktree and re-run the linker).
+
+For ad-hoc per-worktree values (alternate ports, etc.), use
+`.env.local` — loaded by Next.js and NestJS on top of `.env`, and
+never touched by the hook.
+
+## Cleaning up dead worktree databases
+
+The hook creates `compdev_` databases but doesn't drop them when
+you remove a worktree (git has no pre-worktree-remove hook). To clean
+up old ones occasionally:
+
+```sh
+# List all compdev_* DBs
+psql "$(grep ^DATABASE_URL= packages/db/.env | cut -d= -f2-)" \
+ -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'compdev\\_%'"
+
+# Drop a specific one
+psql "" -c 'DROP DATABASE "compdev_old_feature"'
+```
diff --git a/.githooks/post-checkout b/.githooks/post-checkout
new file mode 100755
index 0000000000..a7ce66336f
--- /dev/null
+++ b/.githooks/post-checkout
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Auto-link .env* files from the main worktree into freshly created worktrees.
+#
+# post-checkout fires for every `git checkout`, `git switch`, and
+# `git worktree add`. This script uses two layered checks to run ONLY for
+# fresh worktree creation:
+#
+# 1. `` equals the null SHA — git fills this in only when there
+# was no previous HEAD in the current directory (fresh checkout).
+# Normal branch or file checkouts have a real SHA here.
+#
+# 2. The current worktree is not the main worktree — filters the fresh-clone
+# case (which also gets the null SHA on its first checkout).
+#
+# Combined, the only invocation that satisfies both is `git worktree add`.
+set -euo pipefail
+
+prev="$1"
+
+# Check 1: only proceed if this is a fresh checkout (no previous HEAD).
+[[ "$prev" == "0000000000000000000000000000000000000000" ]] || exit 0
+
+main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}')
+cur=$(git rev-parse --show-toplevel)
+
+# Check 2: don't run on the main worktree (fresh clone case).
+[[ "$main" == "$cur" ]] && exit 0
+
+# Spin up an isolated Postgres database for this worktree so branches with
+# divergent migrations don't step on each other. The returned URL is fed
+# into the linker so .env files with DATABASE_URL point at the new DB.
+# Skippable via SKIP_WORKTREE_DB=1.
+iso_url=""
+if [[ "${SKIP_WORKTREE_DB:-}" != "1" ]]; then
+ # Script prints the isolated URL on stdout; logs go to stderr (inherited).
+ iso_url=$("$main/scripts/setup-worktree-db.sh" "$cur") || {
+ echo "post-checkout: setup-worktree-db failed — continuing without DB isolation" >&2
+ iso_url=""
+ }
+fi
+
+# Delegate env setup. When $iso_url is non-empty, env files with
+# DATABASE_URL are rewritten (copy, not symlink) to point at the isolated DB.
+ISOLATED_DATABASE_URL="$iso_url" "$main/scripts/link-worktree-envs.sh" "$cur" || {
+ echo "post-checkout: failed to link envs (continuing)" >&2
+}
+
+# Envs must be in place first because `db:migrate` and `db:generate` read
+# DATABASE_URL from .env. Skippable via SKIP_WORKTREE_SETUP=1 for callers
+# that want the worktree without the install cycle.
+"$main/scripts/setup-worktree.sh" "$cur" || {
+ echo "post-checkout: setup-worktree failed (worktree is still usable, fix manually)" >&2
+}
diff --git a/apps/api/package.json b/apps/api/package.json
index 4bf6aa80c4..f3f51de438 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -184,6 +184,7 @@
"deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy",
"dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"",
"dev:nest": "nest start --watch",
+ "dev:no-trigger": "nest start --watch",
"dev:trigger": "trigger dev",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
diff --git a/apps/app/package.json b/apps/app/package.json
index a2a5f22020..cfe571db22 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -192,6 +192,7 @@
"db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app",
"deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy",
"dev": "bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"NODE_OPTIONS='--no-deprecation' next dev --turbo -p 3000\" \"NODE_OPTIONS='--no-deprecation' trigger dev\"",
+ "dev:no-trigger": "NODE_OPTIONS='--no-deprecation' next dev --turbo -p 3000",
"lint": "eslint . && prettier --check .",
"prebuild": "bun run db:generate",
"postinstall": "prisma generate --schema=./prisma/schema || exit 0",
diff --git a/scripts/create-database.mjs b/scripts/create-database.mjs
new file mode 100644
index 0000000000..db13451276
--- /dev/null
+++ b/scripts/create-database.mjs
@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+// Create a Postgres database if it doesn't already exist.
+// Usage:
+// node scripts/create-database.mjs
+//
+// The management URL should point at the `postgres` maintenance database
+// on the same host/credentials as the target. Exits 0 on success (whether
+// the DB was created or already existed).
+import { Client } from 'pg';
+
+const [mgmtUrl, dbName] = process.argv.slice(2);
+if (!mgmtUrl || !dbName) {
+ console.error('usage: node create-database.mjs ');
+ process.exit(2);
+}
+
+const client = new Client({ connectionString: mgmtUrl });
+try {
+ await client.connect();
+ const { rowCount } = await client.query(
+ 'SELECT 1 FROM pg_database WHERE datname = $1',
+ [dbName],
+ );
+ if (rowCount === 0) {
+ // dbName is validated upstream (shell slug: [a-z0-9_]+), safe to interpolate.
+ await client.query(`CREATE DATABASE "${dbName}"`);
+ console.error(`create-database: created ${dbName}`);
+ } else {
+ console.error(`create-database: ${dbName} already exists`);
+ }
+} finally {
+ await client.end();
+}
diff --git a/scripts/link-worktree-envs.sh b/scripts/link-worktree-envs.sh
new file mode 100755
index 0000000000..95442ef970
--- /dev/null
+++ b/scripts/link-worktree-envs.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Symlink every .env* file from the main worktree into a target worktree.
+#
+# Usage:
+# scripts/link-worktree-envs.sh [target-worktree-path]
+#
+# If no target is given, uses the current working directory. The script is
+# safe to re-run — it overwrites existing symlinks with `ln -sfn`.
+#
+# Also invoked automatically by .githooks/post-checkout after
+# `git worktree add`; run it manually to backfill worktrees created before
+# the hook was installed.
+set -euo pipefail
+
+target="${1:-$(pwd)}"
+
+# The first line of `git worktree list --porcelain` is the main worktree.
+main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}')
+
+if [[ -z "$main" ]]; then
+ echo "error: could not resolve main worktree" >&2
+ exit 1
+fi
+
+# Resolve target to absolute path
+target=$(cd "$target" && pwd)
+
+if [[ "$target" == "$main" ]]; then
+ echo "skip: target is the main worktree ($main)" >&2
+ exit 0
+fi
+
+cd "$main"
+
+# Optional: per-worktree DATABASE_URL override. When set, any .env file
+# whose source contains DATABASE_URL= will be copied (not symlinked) and
+# rewritten to point at the isolated URL. All other .env files stay as
+# symlinks so API keys and such still auto-propagate.
+iso_url="${ISOLATED_DATABASE_URL:-}"
+
+linked=0
+rewritten=0
+while IFS= read -r -d '' env; do
+ rel="${env#./}"
+ src="$main/$rel"
+ dest="$target/$rel"
+ mkdir -p "$(dirname "$dest")"
+
+ if [[ -n "$iso_url" ]] && grep -qE '^DATABASE_URL=' "$src"; then
+ # Copy + rewrite DATABASE_URL (and DIRECT_URL, if present).
+ rm -f "$dest"
+ awk -v u="$iso_url" '
+ /^DATABASE_URL=/ { print "DATABASE_URL=" u; next }
+ /^DIRECT_URL=/ { print "DIRECT_URL=" u; next }
+ { print }
+ ' "$src" > "$dest"
+ rewritten=$((rewritten + 1))
+ else
+ ln -sfn "$src" "$dest"
+ linked=$((linked + 1))
+ fi
+done < <(
+ find . -maxdepth 3 -name ".env*" \
+ ! -name "*.example" \
+ -not -path "./node_modules/*" \
+ -not -path "./.worktrees/*" \
+ -not -path "./.git/*" \
+ -print0
+)
+
+if [[ -n "$iso_url" ]]; then
+ echo "linked $linked env file(s), rewrote DATABASE_URL in $rewritten file(s) -> isolated URL" >&2
+else
+ echo "linked $linked env file(s) from $main into $target" >&2
+fi
diff --git a/scripts/setup-worktree-db.sh b/scripts/setup-worktree-db.sh
new file mode 100755
index 0000000000..df7665f8db
--- /dev/null
+++ b/scripts/setup-worktree-db.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# Create a per-worktree Postgres database and print its connection URL on
+# stdout. Uses the connection info from the main worktree's packages/db/.env.
+#
+# Usage:
+# scripts/setup-worktree-db.sh [target-worktree-path]
+#
+# Output (stdout): the isolated DATABASE_URL (postgresql://...)
+# Logs go to stderr.
+#
+# Requires `psql` on PATH.
+set -euo pipefail
+
+# Resolve paths from the script's own location so this works regardless of
+# which worktree holds the script file (main vs. any worktree vs. the
+# hook's own copy at core.hooksPath).
+script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+
+target="${1:-$(pwd)}"
+target=$(cd "$target" && pwd)
+
+main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}')
+if [[ "$target" == "$main" ]]; then
+ echo "setup-worktree-db: target is the main worktree — skipping" >&2
+ exit 0
+fi
+
+# Slug: basename of the worktree dir, lowercased, hyphens → underscores,
+# anything non-[a-z0-9_] dropped. Postgres identifiers don't tolerate hyphens.
+raw=$(basename "$target")
+slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr '-' '_' | tr -cd 'a-z0-9_')
+db_name="compdev_${slug}"
+
+# Source connection info from main's packages/db/.env.
+src_env="$main/packages/db/.env"
+if [[ ! -f "$src_env" ]]; then
+ echo "setup-worktree-db: $src_env not found — can't derive connection info" >&2
+ exit 1
+fi
+
+src_url=$(grep -E '^DATABASE_URL=' "$src_env" | head -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")
+if [[ -z "$src_url" ]]; then
+ echo "setup-worktree-db: DATABASE_URL missing in $src_env" >&2
+ exit 1
+fi
+
+# Replace the database-name segment in the URL (path after host:port, before ?).
+# postgresql://user:pass@host:port/dbname?params → .../?params
+iso_url=$(printf '%s' "$src_url" \
+ | perl -pe 's{^(postgres(?:ql)?://[^/]+/)[^?]*(\?.*)?$}{$1'"$db_name"'$2};')
+
+if [[ -z "$iso_url" || "$iso_url" == "$src_url" ]]; then
+ echo "setup-worktree-db: failed to rewrite DATABASE_URL (got: $iso_url)" >&2
+ exit 1
+fi
+
+# Connect to the `postgres` maintenance database (same host/creds) to run
+# CREATE DATABASE. Build the maintenance URL the same way.
+mgmt_url=$(printf '%s' "$src_url" \
+ | perl -pe 's{^(postgres(?:ql)?://[^/]+/)[^?]*(\?.*)?$}{${1}postgres$2};')
+
+# Use the main worktree's node_modules (which always has `pg` via
+# @prisma/adapter-pg) so we don't require psql on PATH.
+if [[ ! -d "$main/node_modules/pg" ]]; then
+ echo "setup-worktree-db: $main/node_modules/pg missing — run bun install in the main worktree first" >&2
+ exit 1
+fi
+
+(cd "$main" && bun run "$script_dir/create-database.mjs" "$mgmt_url" "$db_name" >&2)
+
+# stdout: the isolated URL for callers to consume
+printf '%s\n' "$iso_url"
diff --git a/scripts/setup-worktree.sh b/scripts/setup-worktree.sh
new file mode 100755
index 0000000000..273955b2af
--- /dev/null
+++ b/scripts/setup-worktree.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Prepare a freshly created worktree: install deps, generate Prisma client,
+# and optionally run the full build. Assumes `.env*` files are already
+# linked in (the post-checkout hook does that before calling this script).
+#
+# Usage:
+# scripts/setup-worktree.sh [target-worktree-path]
+#
+# Environment toggles:
+# SKIP_WORKTREE_SETUP=1 — skip everything (just link envs)
+# SETUP_WORKTREE_WITH_BUILD=1 — also run `bun run build` (slow, minutes;
+# unnecessary for `dev`, tests, typechecks)
+#
+# Defaults to the current working directory.
+set -euo pipefail
+
+if [[ "${SKIP_WORKTREE_SETUP:-}" == "1" ]]; then
+ echo "setup-worktree: SKIP_WORKTREE_SETUP=1 — skipping install + build" >&2
+ exit 0
+fi
+
+target="${1:-$(pwd)}"
+target=$(cd "$target" && pwd)
+
+# Refuse to run on the main worktree — it already has its own lifecycle.
+main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}')
+if [[ "$target" == "$main" ]]; then
+ echo "setup-worktree: target is the main worktree — skipping" >&2
+ exit 0
+fi
+
+cd "$target"
+
+echo "▸ Installing dependencies (bun install) in $target"
+bun install
+
+echo "▸ Applying Prisma migrations (cd packages/db && bun run db:migrate)"
+(cd packages/db && bun run db:migrate)
+
+echo "▸ Generating Prisma client (bun run db:generate)"
+bun run db:generate
+
+if [[ "${SETUP_WORKTREE_WITH_BUILD:-}" == "1" ]]; then
+ echo "▸ Building all packages (bun run build)"
+ bun run build
+else
+ echo "▸ Skipping build — set SETUP_WORKTREE_WITH_BUILD=1 to include it"
+fi
+
+echo "✓ Worktree setup complete: $target"