snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s

This commit is contained in:
upstream-mirror
2026-09-18 01:48:50 +05:00
commit 93ecc1a6f8
787 changed files with 75240 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
---
name: adr-writing
description: Architecture Decision Record format and rules for the developer agent. Load when the implementation introduces a structural decision worth remembering 6 months later — new pattern, new dependency, deliberate departure from convention, non-obvious schema choice. Skip for naming and file-layout micro-choices. No ADR is the default.
---
# ADR — Architecture Decision Record
Load only when you have a structural decision to record. Default behaviour for any developer is **no ADR**.
## When to write one
Yes:
- New pattern introduced into the codebase (not just used once — recurring).
- New runtime dependency (npm package, new Supabase extension, external service).
- Deliberate departure from an existing convention.
- Non-obvious schema choice (denormalisation, computed column, soft-delete strategy).
No:
- File naming, folder placement.
- A bug fix or refactor with no structural impact.
- Style decisions already captured by lint/prettier.
## File naming — namespaced by session + ticket
Path: `<WORKTREE_PATH>/adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`
- `<SESSION_SHORT_ID>` is your session id — the prefix of your `BRANCH_NAME` before the `/` (e.g. `BRANCH_NAME=58e4ec51/TASK-004` → `58e4ec51`). It scopes the ADR to this session.
- `<TASK-XXX>` is the literal ticket id. Together with the session id it forms the namespace — there is no separate sequence number (e.g. `ADR-58e4ec51-TASK-004-supabase-rls-strategy.md`).
- `<slug>` is kebab-case, ASCII, ≤ 40 chars.
- One ADR per ticket is the common case. If the same ticket genuinely needs two ADRs, the distinct slugs keep them apart.
**Why the session+ticket namespace**: in a wave, N developers run in parallel on worktrees branched from the same base. A monotonically-incremented `ADR-NNN-…` scheme makes every worker independently pick the same next number (e.g. all land on `ADR-003`); different slugs hide the collision from `git merge`, so duplicate numbers land in `$CLAUDE_PROJECT_DIR/adr/` silently. `<TASK-XXX>` removes the intra-wave collision; the `<SESSION_SHORT_ID>` prefix removes the cross-session one — ticket numbering restarts at TASK-001 each session, so two sessions both producing a `TASK-004` would otherwise overwrite each other's ADR in the shared `$CLAUDE_PROJECT_DIR/adr/`.
## Source-code reference
One comment at the most representative line of the change, pointing at the ADR you just wrote:
- TS/JS/CSS: `// See adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`
- Python/SQL/shell: `# See adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`
No need for more than one reference; reviewers follow the link.
## Commit
The ADR + its reference comment go in the same commit as the code they describe (WORKFLOW step 3). Subject:
```
docs(TASK-XXX): ADR-<SESSION_SHORT_ID>-<TASK-XXX> <title>
```
Reviewers see the ADR alongside the implementation.
## Template (≤ 25 lines)
```markdown
# ADR-<SESSION_SHORT_ID>-<TASK-XXX> — <decision title>
- **Date**: YYYY-MM-DD
- **Ticket**: TASK-XXX
- **Session**: <SESSION_SHORT_ID>
## Context
2–4 lines on what made this decision necessary.
## Decision
1–3 lines on what was chosen.
## Consequences
- Up to 4 bullets: what this enables, costs, locks in.
## Alternatives considered
- Up to 3, one line each, with reason for rejection. If none were captured, write `- _Not captured at decision time._` — never invent.
```
Keep it short. An ADR longer than 25 lines is a sign that the decision either belongs in code comments or is actually several decisions that should be split.
## Red Flags
- An ADR written for a naming, file-placement, or pure-style choice (no ADR is the default).
- A monotonic `ADR-NNN-…` filename instead of the `ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>` namespace parallel workers collide on the same number.
- An ADR file written outside the worktree, or committed separately from the code it describes.
- `Alternatives considered` invented after the fact instead of `- _Not captured at decision time._`.
- An ADR past 25 lines likely belongs in code comments or is several decisions.
## Verification
- [ ] The change is a genuine structural decision (new pattern, dependency, convention departure, non-obvious schema) otherwise no ADR.
- [ ] Filename is `<WORKTREE_PATH>/adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`.
- [ ] Exactly one source-code reference comment points at the ADR.
- [ ] ADR + reference comment are in the same commit as the code, with the `docs(TASK-XXX):` subject.
- [ ] Body follows the template and is ≤ 25 lines.

View File

@@ -0,0 +1,61 @@
---
name: backend-dev
description: Coding practices for backend development in Atomic CRM. Use when deciding whether backend logic is needed, or when creating/modifying database migrations, views, triggers, RLS policies, edge functions, or custom dataProvider methods that call Supabase APIs.
---
# Backend Development
Reference for backend conventions. There is no custom backend server all
server-side logic uses Supabase: PostgreSQL (tables, views, triggers, RLS),
Auth API, Storage, and Edge Functions. Read the relevant section before writing backend code, then check the Red Flags and Verification list.
## When to Use
- Deciding *whether* a change needs backend logic at all (default: prefer the frontend).
- Creating or modifying database views, triggers, or RLS policies.
- Writing or changing a Supabase edge function, or a custom dataProvider method that calls Supabase.
For the SQL migration mechanics themselves, see `Skill({skill: "writing-migrations"})`.
For the frontend side of a dataProvider method, see `Skill({skill: "frontend-dev"})`.
## Deciding where logic belongs
Prefer frontend-only solutions via custom dataProvider methods calling the PostgREST API.
When backend logic is needed:
- **Aggregation/read optimization**: Create a database view (`CREATE OR REPLACE VIEW` in a new migration). PostgREST exposes views like tables. When underlying table columns change, update the `contacts_summary` and `companies_summary` views too.
- **Complex mutations** (multi-table writes): Create a Supabase edge function in Deno. Stored procedures via RPC are less preferred (code lives in migrations, harder to maintain). On the frontend, expose the edge function as a custom dataProvider method (using `httpClient(`${supabaseUrl}/functions/v1/<name>`)`) and call it via react-query. (e.g. `salesCreate()` → `/functions/v1/users`, `mergeContacts()` → `/functions/v1/merge_contacts`)
## Edge function conventions
- Shared utils in `supabase/functions/_shared/` — reuse `authentication.ts`, `supabaseAdmin.ts`, `cors.ts`, `utils.ts`
- Follow the middleware chain pattern: CORS preflight → `authenticate()` → handler
- `verify_jwt = false` in config.toml, so JWT validation is manual via `authenticate()`
## Other conventions
- New tables need RLS policies and the auto-set `sales_id` trigger (see migration `20260108160722`)
## RLS enforcement pitfalls (security-critical)
- A trigger/function that **counts rows to enforce a limit** (capacity, quota, balance, subscription depletion) must be `SECURITY DEFINER` with a fixed `search_path`, or delegate to one. A `SECURITY INVOKER` count runs under the caller's RLS, sees only the rows that caller may read, under-counts, and the limit silently never fires (e.g. `session_booked_count` is DEFINER for exactly this reason).
- A `WITH CHECK` clause must constrain **every column a non-admin may set**, not just ownership. `with check (contact_id = current_user_contact())` alone lets the caller forge any other column (`status`, `type`, amounts, flags) via PostgREST — privilege escalation / payment bypass. Whitelist allowed values in the policy, a trigger, or a `CHECK` constraint; never trust a client-chosen `status`/`type`.
## Red Flags
- Reaching for an edge function or RPC where a custom dataProvider method against PostgREST would do.
- Adding a column to a table that feeds `contacts_summary`/`companies_summary` without updating the view.
- A new table without RLS policies or the `sales_id` trigger.
- A row-counting limit enforced by a `SECURITY INVOKER` function it under-counts and never fires.
- A `WITH CHECK` that constrains only ownership while leaving `status`/`type`/amounts client-settable.
- An edge function handler that runs before `authenticate()` in the middleware chain.
## Verification
- [ ] The logic genuinely needs the backend no frontend-only path was available.
- [ ] New/changed views keep `contacts_summary` and `companies_summary` in sync.
- [ ] New tables have RLS policies and the `sales_id` trigger.
- [ ] Any limit-enforcing count runs `SECURITY DEFINER` with a fixed `search_path`.
- [ ] Every column a non-admin can set is constrained by policy, trigger, or `CHECK`.
- [ ] Edge functions follow CORS → `authenticate()` → handler.

View File

@@ -0,0 +1,101 @@
---
name: delete-initial-resource
description: Remove one or more of the initial CRM resources (contacts, companies, deals, tags, tasks) from the codebase. Use when the user asks to delete, remove, or strip out one or several of these built-in resources. Runs the delete-initial-resource.ts script to drop each resource's own folder, then guides cleanup of every file that references them.
---
# delete-initial-resource
## Overview
Removes one or more of the five initial Atomic CRM resources (`contacts`, `companies`, `deals`, `tags`, `tasks`) and every reference to them. **Irreversible confirm the target(s) with the user first; rely on git to recover.** Exit criterion: `make typecheck && make lint` is clean and no live reference to the deleted resource(s) survives in `src/` or `supabase/`.
## When to Use
- The user asks to delete, remove, or strip out one or several built-in CRM resources.
- Not for *custom* entities added during setup only the five built-ins above.
For the backend migration mechanics, see `Skill({skill: "backend-dev"})` and `Skill({skill: "writing-migrations"})`.
## Steps
1. **Confirm the target(s)**, then read each one's file before editing:
- `contacts` → [`contacts.md`](contacts.md) — spine; **confirm cascade scope first**
- `companies` → [`companies.md`](companies.md) — spine/link; **confirm cascade scope first**
- `deals` → [`deals.md`](deals.md)
- `tags` → [`tags.md`](tags.md)
- `tasks` → [`tasks.md`](tasks.md)
2. **Run the script** (below) — deletes each `src/components/atomic-crm/<resource>/` folder and prints the dependent files to clean.
3. **Clean each dependent file** using the "Shapes" patterns below + the per-resource file.
4. **Verify** (below).
Deleting several at once: clean from the most-coupled resource outward; clean a shared file **once** for both; and reconcile interacting union guidance — deleting both `contacts` and `deals` removes the notes/activity-log reference *entirely* rather than each narrowing it, so don't half-apply each file's "narrow the union" note.
## The script
```bash
node .claude/skills/delete-initial-resource/delete-initial-resource.ts <resource> [<resource> ...]
```
Validates the names, deletes each resource's folder, and prints the **merged, de-duplicated dependent-file list** (files inside a just-deleted folder are dropped). It does **not** edit those files — that's your job. Each entry in the script's `dependentFiles` map has a `//` comment saying *what* to remove; read it alongside the printed list, and update the map if the codebase grows new references.
## Shapes a resource takes
A resource is rarely just a folder. The later shapes are invisible to a `\b<resource>\b` grep:
- **Standalone** — imports, JSX, routes, menu/nav, dashboard widgets, the `<Resource>` entry in `root/CRM.tsx`. Wrapper components *outside* the folder that only render the resource: delete outright, then clean importers.
- **Field/column on another record** — fans out far wider: the `types.ts` type, `providers/commons/mergeContacts.ts`, CSV/JSON import, i18n, stories/tests, `StoryWrapper` builders, and the sample CSVs (header **and** every row). For a *link* column, decide per-link: orphaned shell (typed, defaulted) or drop — remove the UI either way.
- **Aggregated `nb_<resource>`** (from the `_summary` views) lands on a record type, read across shows/lists/filters/generators. **Invisible to a `\b<resource>\b` grep** (the `_` is a word char) — grep `nb_<resource>` and the denormalized `company_name` separately.
- **Shared subsystems** — notes (`reference: "contacts" | "deals"`) and the activity log (`"company" | "contact" | "deal" | "all"` union). Narrow the union, delete the per-type render components (`ActivityLog*Created.tsx`) + dead branches, prune the `providers/commons/activity.ts` fetcher + `consts.ts`/`types.ts` members. Surviving `=== "<other>"` checks stay valid — only the deleted member's branches go.
- **Config props** (`taskTypes`, `dealStages`, `companySectors`) live in `root/defaultConfiguration.ts`, `ConfigurationContext.tsx`, `CRM.tsx` (default + jsdoc + store seed), `App.tsx`, and `SettingsPage.tsx` (the `<Card>` **and** the section-list entry). **Not always named after the resource** — grep the settings `<Card>` for other keys (Deals owned `currency`) and flag borderline-general ones rather than dropping them silently.
- **Custom dataProvider methods** live in **both** providers; `CrmDataProvider` derives from the supabase one, so removing them there drops them from the type. Also clean the lifecycle-callback blocks in both providers, the `sales` `beforeDelete` reassignment, the supabase `<resource> -> <resource>_summary` `getList` routing, and the FakeRest generators (which *drive* record creation).
- **JSON importer** (`misc/useImportFromJson.ts`) treats `contacts`/`companies`/`notes`/`tasks` as top-level types (`import<X>` fn, `$.<x>.*` path, a `TYPES` arm + switch, guards, `stats`/`failedImports`/`idsMaps` keys). Mirror in `ImportPage.tsx` + `import-sample.json`; the CSV importer (`useContactImport.tsx`) has parallel mappings.
- Check `useCallback`/`useMemo` deps — a stale `getTags`/`getCompanies` there is invisible until `tsc`.
## Backend (the script only touches frontend)
See the `backend-dev` skill for the migration workflow. In `supabase/schemas/`:
- Drop/adjust the table + view, generate a migration (`npx supabase db diff --local -f remove_<resource>`); update `contacts_summary`/`companies_summary` if they aggregated it.
- **`activity_log`** is a `UNION ALL` with the same column set per branch — remove the resource's columns from **every** branch (keep them aligned), delete its dedicated branch(es), and drop the matching snake→camel rename in the supabase `getList("activity_log")`.
- `06_grants.sql` grants the table **and** its `<resource>_id_seq` (drop both). `05_policies.sql` has an RLS-enable line **and** `create policy` statements (drop both).
- A resource used inside a SQL function (`merge_contacts`): drop those lines + the unused local var. Editing `02_functions.sql` regenerates the whole function (expected) — renumber leftover step comments.
- **Orphan check:** deleting triggers can orphan helpers — but verify shared callers first (`get_domain_favicon` survives a `companies` delete because contact avatars also call it).
- **Edge functions** are separate and easy to miss: `merge_contacts/`, `_shared/db.ts` (Kysely types), `postmark/` (inbound email), and `mcp/` (an AI subsystem whose depth varies per resource). When a surviving function loses a capability, **flag it to the user**.
## Rationalizations
| Rationalization | Reality |
|---|---|
| "I deleted the folder, the resource is gone." | A resource is also fields on other records, `nb_<resource>` aggregates, shared subsystems, config props, dataProvider methods, and SQL — the folder is the easy part. |
| "A `\b<resource>\b` grep came back clean, so it's clean." | `nb_<resource>` and denormalized `company_name` are invisible to that grep (`_` is a word char). Grep them separately. |
| "I'll remove the i18n key from English only." | The French catalog is type-checked against English — a one-sided removal is a `tsc` error. Remove from both or neither. |
| "The script edits the dependent files for me." | The script only deletes folders and prints the list. Editing every dependent file is your job. |
| "Typecheck passes, so the deletion is complete." | `tsc` misses broken views/functions/grants. Reset the DB and grep for live references too. |
## Red Flags
- Editing files inside `supabase/migrations/` (never — they are append-only history).
- A one-sided i18n key removal (English without French, or vice versa).
- Relying on a single `\b<resource>\b` grep and skipping `nb_<resource>` / `company_name`.
- Deleting a shared SQL helper or edge-function capability without checking surviving callers.
- Deleting several resources but half-applying each file's "narrow the union" note.
- Skipping the `db reset` check when Supabase is running.
## Verification
```bash
make typecheck && make lint
```
Resolve whatever `tsc` surfaces (the dependent-file list is a guide, not a guarantee). Then `grep -rniE "\b<resource>\b"` over `src/`+`supabase/`, plus **separate** greps for `nb_<resource>`, `company_name`, and config-prop names (`<resource>Types`, `companySectors`, `currency`). Watch for benign/substring false positives (each resource file lists its own); never edit `supabase/migrations/`.
- [ ] `make typecheck && make lint` is clean.
- [ ] `\b<resource>\b`, `nb_<resource>`, `company_name`, and config-prop greps return only benign matches.
- [ ] i18n keys removed from both catalogs, or neither.
- [ ] Backend: table/view/policies/grants/sequences and any function references handled in `supabase/schemas/` (not `migrations/`).
- [ ] If Supabase is running, `npx supabase db reset --local` replays cleanly.
- [ ] Any lost edge-function capability flagged to the user.
**i18n is all-or-nothing:** `frenchCrmMessages.ts` is type-checked against the type derived from `englishCrmMessages.ts`, so remove a key from **both** catalogs or **neither** (a one-sided removal is a `tsc` error). Dead `resources.<x>.*` keys are harmless — flag rather than force. `frenchCrmMessages.ts` uses literal `…` escapes; if an exact-string `Edit` fails on a block spanning one, fall back to `sed -i '<from>,<to>d'`.
If Supabase is running, `npx supabase db reset --local` replays the chain + `seed.sql` and catches a broken view/function/grant `db diff` misses (benign `index "…_pkey" does not exist` NOTICEs expected). On Node 22 (see `.nvmrc`), `make test` (vitest) is also available.

View File

@@ -0,0 +1,26 @@
# Deleting `companies`
A **spine/link** resource: it owns the `companies` table *and* is a `company_id` link on **both** `contacts` and `deals`, its `companies_summary` view aggregates `nb_contacts`/`nb_deals`, and it adds a **denormalized `company_name`** to the `Contact` type. So it fans out far wider than its folder (≈50 frontend files + backend).
## Confirm cascade scope first
**Remove `company_id` fully** — drop the column from the `Contact`/`Deal` types, the CSV import/export, `mergeContacts`, the fakerest generators, and i18n. This full removal is what makes the deletion large.
## Frontend specifics
- **Activity log** — a dedicated `company.created` branch (delete `ActivityLogCompanyCreated.tsx` outright + the branch in `ActivityLogIterator.tsx` + `COMPANY_CREATED` in `consts.ts`/`types.ts`) **and** a `"company"` member in the context union. Narrowing the union turns the `=== "company"` checks in `ActivityLogContactCreated`/`ActivityLogContactNoteCreated`/`ActivityLogDealCreated`/`ActivityLogDealNoteCreated` into type errors — prune those branches (the `company_id` "to/at `<company>`" ReferenceField + the `CompanyAvatar` in the deal-note header). `ActivityLog.tsx` has a `companyId` prop + `company_id` filter; `providers/commons/activity.ts` has a `getNewCompanies` fetcher + `companyId` param.
- **`company_id` link** on contacts (`ContactInputs` selector, `ContactShow`/`ContactListContent` avatar+name, the exporter's `fetchRelatedRecords` + the `company` CSV column, `contactModel.ts`'s vCard `ORG:` line, `ExportVCardButton.tsx`) and on deals (`DealInputs` required selector, `DealCard`/`DealShow`/`DealEdit` avatar, `DealList` filter), plus the note-header `CompanyAvatar` in `notes/Note.tsx`.
- **Denormalized `company_name`** (from `companies_summary`'s `c.name as company_name`) — read in `ContactOption.tsx`, `deals/ContactList.tsx`, `dashboard/HotContacts.tsx`, and the `position_at_company` rendering. **Invisible to a `\bcompan(y|ies)\b` grep** — grep `company_name` separately.
- **`companySectors` config prop** — see SKILL.md "Shapes a resource takes" for the standard config-prop locations. Companies-specific: the `<Card>` holds only a `companySectors` `ArrayInput` (no `validateItemsInUse`), and it's also read in `useImportFromJson.ts` to validate an imported `sector`.
- **Providers** — supabase `getList`/`getOne` route `companies -> companies_summary` (drop both); the contacts FTS list adds `company_name`. FakeRest has `processCompanyLogo` (favicon), `fetchAndUpdateCompanyData` (stamps `company_name` onto contacts, called from the contacts `beforeCreate`/`beforeUpdate`), `updateCompany` (maintains `nb_contacts`/`nb_deals` from the contacts/deals `afterCreate`/`afterDelete`), the whole `companies` callback block, the `companies` arm of the `sales` `beforeDelete` reassignment, and the `getActivityLog(dataProvider, companyId, salesId)` call (drop the `companyId` arg + the `companyId`/`company_id` filter plumbing in `providers/commons/activity.ts`). Delete `providers/commons/getCompanyAvatar.ts` + test.
- **FakeRest generators are deeply company-coupled** — `dataGenerator/companies.ts` deleted outright + dropped from `index.ts` (mind the order: sales → tags → [companies] → contacts → …) and `types.ts` (`Db.companies`). `contacts.ts` *picks a company* to derive `company_id`/`company_name`/`sales_id`/`first_seen` (+ a per-company `maxContacts` cap), and `deals.ts` *picks a company* then filters contacts by `company_id` for `contact_ids` — rewrite both to pick a random `db.sales` + `randomDate()` (and random contacts for deals). The `faker` `company` namespace import is unrelated — keep it.
- **JSON importer** treats `companies` as a top-level type (`importCompany`, `$.companies.*`, `CompanyImport`/`isCompany`, `mapSizeToCategory`, `companySectors` validation, stats/idsMaps keys) + the `company_id` mapping on imported contacts. Mirror in `ImportPage.tsx` + `import-sample.json`. CSV importer (`useContactImport.tsx`) has a `getCompanies` cache + a `company` column → `company_id`.
- **Mislabeled i18n:** `deals/OnlyMineInput.tsx` uses `resources.companies.filters.only_mine` ("Only companies I manage") even though it filters deals — re-point it to a deals key, don't delete the input.
## Backend
- Drop the `companies` table + `companies_id_seq` grant + RLS-enable line + its 4 policies + table grants. Drop `company_id` on **both** `contacts` and `deals` (FKs `contacts_company_id_fkey`/`deals_company_id_fkey` + indexes `contacts_company_id_idx`/`deals_company_id_idx`). Drop the `companies_summary` view (+ grant). Update `contacts_summary`: drop `co.company_id`, the `c.name as company_name` column, the `left join public.companies`, and narrow the `group by` to `co.id`.
- `activity_log` view carries the company pattern (a `company` column on **every** branch **and** a dedicated `company.created` branch) — remove the `company` + `company_id` columns from all branches (the `contactNote`/`dealNote` branches only joined a table to fetch `company_id`, so drop those now-unused joins) and delete the branch.
- Drop the company-only function `handle_company_saved` (favicon-from-website trigger) + its `company_saved` trigger + grant, and the `set_company_sales_id_trigger`. Drop the `company_id` line from `merge_contacts`. **Orphan subtlety:** `handle_company_saved` calls `get_domain_favicon`, but so does `get_avatar_for_email` (contact avatars) — so it is **not** orphaned; **keep** `get_domain_favicon` and the `favicons_excluded_domains` table.
- **Edge functions:** unlike `contacts` (delete the whole function), **keep** `postmark/` and surgically remove the company bits — it auto-creates a company from the sender's email domain (`getOrCreateCompanyFromDomain` in `addNoteToContact.ts`, `extractCompanyName` in `extractMailContactData.ts`) and links the new contact via `company_id`. Delete `getOrCreateCompanyFromDomain`, drop the `domain`/`companyName`/`website` params threaded through `getOrCreateContactFromEmailInfo`/`addNoteToContact`/`index.ts`, drop `company_id` from the contact insert, remove `extractCompanyName` + the `companyName`/`website` output fields + the now-unused `tldts` import from `extractMailContactData.ts`, and update the postmark tests (`addNoteToContact.test.ts`, `extractMailContactData.test.ts`). **Flag that auto-company-creation on inbound email is gone but contact+note creation still works.** Elsewhere: `_shared/db.ts` declares `company_id` on **both** `ContactsTable` and `DealsTable` (no dedicated `CompaniesTable`); `merge_contacts/index.ts` merges `company_id` in `mergeContactData`; `mcp/index.ts` + `mcp/validateSql.test.ts` mention `companies`/`companies_summary` in prompt examples/fixtures (swap for a surviving table). No `config.toml` block changes are needed (postmark + merge_contacts both survive).
## grep — benign matches to leave
- The `faker` `company` namespace import in `dataGenerator/contacts.ts` (used for `bsAdjective()` titles), the `reference="companies"`/`source="company_id"` JSDoc examples in the **mutable** `admin/reference-input.tsx`/`admin/list.tsx`, and email-domain fixtures like `@company.com`/`original.recipient@company.com` in test bodies.
- Dead i18n keys (`resources.companies.*`, `position_at_company`, `you_added_company`/`at_company`, `crm.settings.companies.*`) — flag, don't force (both catalogs or neither). `test-data/contacts.csv` keeps `company_id`/`company_name`/`company` columns — the importer ignores them, so leaving them is harmless; dropping means editing the header **and** all ~500 rows — flag it.

View File

@@ -0,0 +1,29 @@
# Deleting `contacts`
**The spine of the CRM and the widest deletion of the five.** Notes attach to `contacts | deals`, tasks belong only to contacts, `tags` is a field on `Contact`, companies aggregate `nb_contacts`, deals link `contact_ids`, and the activity-log + JSON-import + merge subsystems all revolve around it. Removing it orphans notes/tasks/tags.
## Confirm cascade scope first
Ask the user how far to cascade — the answer changes the whole job:
- **contacts only** — leave notes/tasks/tags as compiling shells (narrowing the notes union to `"deals"` keeps notes valid as long as deals exists; tasks/tags become orphaned shells), or
- **also delete the now-orphaned subsystems**.
- And whether to touch the backend.
## Frontend specifics
- **Shared subsystems** — narrow the notes union to `"deals"` and the activity-log union to drop `"contact"`. Delete `ActivityLogContactCreated.tsx` + `ActivityLogContactNoteCreated.tsx` outright; drop `getNewContactsAndNotes` in `providers/commons/activity.ts`.
- **Typing landmine:** `DealNote.status` is typed `undefined` (a deliberate ContactNote-compat marker in `types.ts`), so retyping `NoteInputs`'s `useFormContext<ContactNote | DealNote>` down to `<DealNote>` collapses `status` to `never` and `tsc` rejects the status-hydration `setValue("status", …)`. Widen it back with `useFormContext<Omit<DealNote, "status"> & { status?: string }>()`.
- **Contact-only *mobile* note components** (dead once contacts is gone) — delete outright: `NoteCreateSheet.tsx`, `NoteEditSheet.tsx`, `NoteShowPage.tsx`, `NotesIteratorMobile.tsx`, `NoteInputsMobile.tsx` (+ their stories/tests). Drop the `NotesIteratorMobile` re-export from `notes/index.ts` and clean importers (`MobileNavigation.tsx`, the mobile `<Resource name="contacts">` note route in `CRM.tsx`).
- **Links to contacts:** `deals.contact_ids` (a selector in `DealInputs.tsx`, a per-deal `<ContactList/>` in `DealShow.tsx` + the standalone `deals/ContactList.tsx` to delete, the "create a contact first" gate in `DealEmpty.tsx`) and `tasks.contact_id` (the whole task contact-linking UI: `AddTask`, `Task`, `TaskCreateSheet`, `TaskEditSheet`, `TaskFormContent`'s `selectContact`, the `showContact`/`filterByContact` threading in `TasksIterator`/`TasksListFilter`/`TasksListByDueDate`). Decide per-link: orphaned shell (`contact_ids: Identifier[]`, default `[]`) vs drop.
- **Custom method:** `mergeContacts(sourceId, targetId)` in both providers (FakeRest → `providers/commons/mergeContacts.ts`, delete it; supabase → the `merge_contacts` edge function). Also `processContactAvatar`/`getContactAvatar` (delete `providers/commons/getContactAvatar.ts` + test), the `contacts`/`contact_notes` + `tasks` `nb_tasks` callbacks, and the supabase `contacts -> contacts_summary` routing + `contact_note -> contactNote` remap.
- **Wrapper components** outside the folder, delete outright: `dashboard/HotContacts.tsx` (contacts widget), `dashboard/DashboardStepper.tsx` (contact-onboarding wizard). `Dashboard.tsx`/`MobileDashboard.tsx` gate the whole render on contact/contactNote counts via the stepper — drop that gating. `LatestNotes.tsx` mixes contact_notes + a `<Contact>` renderer; `dashboard/TasksList.tsx` has an `<AddTask selectContact/>`.
- **JSON importer** is heavily contact-coupled (`importContact`/`importNote` for `contact_notes`, `contactGender`, stats/idsMaps); also `import-sample.json` (drop the contacts + notes sections). `ContactOption.tsx` is a shared option renderer — delete it.
- FakeRest generators: delete `contacts.ts`, `contactNotes.ts`, `finalize.ts` outright; clean `index.ts`/`types.ts`; `deals.ts` (contact_ids), `tasks.ts` (contact_id + nb_tasks), `companies.ts` (nb_contacts seed).
- Layout/login: `Header.tsx`, `MobileNavigation.tsx`, `SignupPage.tsx` (`redirectTo: "/contacts"`), `StoryWrapper.tsx` (`buildContact` + the contacts/contact_notes db keys).
## Backend
- Drop the `contacts` + `contact_notes` tables, the `tasks.contact_id` column (FK + index), the `contacts_summary` view, and the functions `merge_contacts`, `handle_contact_saved`, `handle_contact_note_created_or_updated` outright — plus their triggers/policies/grants and the `contacts_id_seq` / `"contactNotes_id_seq"` grants.
- **Orphaned helpers:** `get_avatar_for_email` (only called by `handle_contact_saved`) and `lowercase_email_jsonb` (only wired to the contact email trigger) — remove them + grants.
- `activity_log` view carries the contact pattern (a `contact` + `contact_note` column on every branch **and** two dedicated branches `contact.created`/`contactNote.created`) — same shape as deals.
- **Edge functions (deepest of the five):** delete the whole `merge_contacts/` function (+ its `[functions.merge_contacts]` block in `config.toml`) and the whole `postmark/` inbound-email function (+ its `[functions.postmark]` block) — this leaves the inbound-email settings UI in `ProfilePage`/`SettingsPageMobile` and the `POSTMARK_*`/`VITE_INBOUND_EMAIL` env vars hollow; **flag that to the user**. In `_shared/db.ts` drop `ContactsTable`/`ContactNotesTable`, the `tasks.contact_id` field, and their `Database` registrations. In `mcp/`: `contacts` (not tasks) drives `CRM_BASE_URL` — the `display_task_list` `contact_name`/`contact_id` schema fields and `taskListUi.ts`'s contact-link rendering exist only to deep-link task→contact, so removing them makes `CRM_BASE_URL` (in `mcp/index.ts`, the `__CRM_BASE_URL__` HTML placeholder, and `supabase/functions/.env`) dead — drop it. Rewrite the `contacts`/`contacts_summary` mentions in the `get_schema`/`query`/`mutate` prompt examples and swap the table name in `mcp/validateSql.test.ts` fixtures for a surviving table.
## grep — benign matches to leave (intentional)
- `resources.companies.field_categories.contact` (a company "Company info" section label in `CompanyInputs.tsx`/`CompanyAside.tsx`, not the contacts resource), the `resource="contacts"` JSDoc examples in `misc/CreateSheet.tsx`/`EditSheet.tsx`, and the now-unused `resources.contacts.*` i18n keys (optional/large — flag, don't force). `supabase/migrations/` keeps historical references — never edit them.

View File

@@ -0,0 +1,19 @@
# Deleting `deals`
`deals` owns the `deals` + `deal_notes` tables but is woven into **two shared subsystems** (notes, activity log), adds an aggregated `nb_deals` to `companies_summary`, ships **three** config props plus a non-eponymous one, and has a custom dataProvider method — so it fans out almost as wide as a column removal.
## Frontend specifics
- **Shared subsystems** — narrow the notes union to `"contacts"` and the activity-log union to drop `"deal"`; prune the `=== "deals"`/`deal_id`/`DealNote` branches (the `=== "contacts"` checks stay valid). Delete `ActivityLogDealCreated.tsx` + `ActivityLogDealNoteCreated.tsx` outright; drop `getNewDealsAndNotes` in `providers/commons/activity.ts` and `DEAL_CREATED`/`DEAL_NOTE_CREATED` + `ActivityDeal*` in `consts.ts`/`types.ts`. Drop the `"deals"` reference test in `NoteInputs.test.tsx`.
- **Config props** — `dealStages`/`dealCategories`/`dealPipelineStatuses` **plus `currency`**, which is deal-scoped but NOT named after the resource (it lived in the Deals `<Card>`; readers are `DealsChart`/`DealsPipeline`/`CompanyShow`'s deal iterator). Removing `currency` is a judgement call — flag it. The Deals `<Card>` also carries a `validateItemsInUse` helper — exported, unit-tested in `SettingsPage.test.ts` (delete that test file outright), backed by a `crm.settings.validation` i18n block; delete all three.
- **Custom method** — `unarchiveDeal(deal)` in **both** providers (drops from `CrmDataProvider` automatically via the supabase provider's return type).
- **Wrapper components** outside the folder, delete outright: `dashboard/DealsChart.tsx`, `dashboard/DealsPipeline.tsx`. `Dashboard.tsx` keeps both an import **and** a `{totalDeal ? <DealsChart/> : null}` JSX site — remove both. `LatestNotes.tsx` fetches `deal_notes` + renders `<Deal/>`.
- **`nb_deals` readers** (invisible to `\bdeals?\b` grep): `CompanyShow.tsx` (deals tab), `CompanyCard.tsx` (badge). `ContactMergeButton.tsx` shows a `dealsCount` preview.
- i18n: `resources.deals`, `nb_deals`, activity/dashboard/`settings.deals`/`settings.validation` keys; `i18nProvider.test.ts` asserts a `resources.deals.*` key.
## Backend
- Drop the `deals` + `deal_notes` tables, grants, policies, sequences. Update `companies_summary` (drop `count(distinct d.id) as nb_deals` + the `left join public.deals`).
- `activity_log` view: `deals` contributed a `deal` + `deal_note` column to **every** branch **and** two dedicated branches (`deal.created`, `dealNote.created`) — remove both, keep column lists aligned, and drop the `deal_note → dealNote` rename in the supabase provider's `getList("activity_log")`.
- **MCP:** **no** dedicated MCP App — only the `DealsTable` Kysely interface in `_shared/db.ts`, the deal-reassignment step in `merge_contacts/index.ts` (its removal leaves the `sql` import unused — drop it), and deal mentions in the `query`/`mutate` **prompt-text examples** in `mcp/index.ts` (rewrite the examples, don't just delete lines).
## grep — benign matches to leave
- `deals` matches `ideal` (an English comment in `RelativeDate.tsx`) and the French `affaires`/`Chiffre d'affaires` (= revenue, not a deal, in `frenchCrmMessages.ts`) — filter them out and don't blind-delete French lines.

View File

@@ -0,0 +1,350 @@
// Run with `node .claude/skills/delete-initial-resource/delete-initial-resource.ts`
// (Node >= 22.18 strips TypeScript types natively; on older Node use `npx -y tsx <...>`).
// `process` is declared locally because this standalone script lives outside the
// project's tsconfig and so doesn't pick up the global @types/node.
declare const process: {
cwd(): string;
argv: string[];
exit(code?: number): never;
};
import { existsSync } from "node:fs";
import { rm } from "node:fs/promises";
// ================================ CONSTANTS ================================
const initialResource = [
"contacts",
"companies",
"deals",
"tags",
"tasks",
] as const;
const dependentFiles: ResourceFiles = {
companies: [
"src/components/atomic-crm/dataImport/useCompanyImport.ts",
"src/components/atomic-crm/dataImport/companies_sample.csv",
"src/components/atomic-crm/dataImport/useCompanyResolver.ts",
"src/components/atomic-crm/dataImport/useImportableResources.ts",
"src/components/atomic-crm/types.ts",
"src/components/atomic-crm/consts.ts",
"src/components/atomic-crm/activity/ActivityLogCompanyCreated.tsx",
"src/components/atomic-crm/activity/ActivityLog.tsx",
"src/components/atomic-crm/activity/ActivityLogContext.tsx",
"src/components/atomic-crm/activity/ActivityLogIterator.tsx",
"src/components/atomic-crm/activity/ActivityLogContactCreated.tsx",
"src/components/atomic-crm/activity/ActivityLogContactNoteCreated.tsx",
"src/components/atomic-crm/activity/ActivityLogDealCreated.tsx",
"src/components/atomic-crm/activity/ActivityLogDealNoteCreated.tsx",
"src/components/atomic-crm/providers/commons/activity.ts",
"src/components/atomic-crm/root/defaultConfiguration.ts",
"src/components/atomic-crm/root/ConfigurationContext.tsx",
"src/components/atomic-crm/settings/SettingsPage.tsx",
"src/App.tsx",
"src/components/atomic-crm/contacts/ContactInputs.tsx",
"src/components/atomic-crm/contacts/ContactShow.tsx",
"src/components/atomic-crm/contacts/ContactListContent.tsx",
"src/components/atomic-crm/contacts/ContactList.tsx",
"src/components/atomic-crm/contacts/contactModel.ts",
"src/components/atomic-crm/contacts/ExportVCardButton.tsx",
"src/components/atomic-crm/contacts/useContactImport.tsx",
"src/components/atomic-crm/contacts/Avatar.tsx",
"src/components/atomic-crm/contacts/contacts_export.csv",
"src/components/atomic-crm/deals/DealInputs.tsx",
"src/components/atomic-crm/deals/DealCard.tsx",
"src/components/atomic-crm/deals/DealShow.tsx",
"src/components/atomic-crm/deals/DealEdit.tsx",
"src/components/atomic-crm/deals/DealList.tsx",
"src/components/atomic-crm/deals/OnlyMineInput.tsx",
"src/components/atomic-crm/deals/ContactList.tsx",
"src/components/atomic-crm/notes/Note.tsx",
"src/components/atomic-crm/dashboard/HotContacts.tsx",
"src/components/atomic-crm/dashboard/DealsPipeline.tsx",
"src/components/atomic-crm/misc/ContactOption.tsx",
"src/components/atomic-crm/misc/useImportFromJson.ts",
"src/components/atomic-crm/misc/ImportPage.tsx",
"src/components/atomic-crm/misc/import-sample.json",
"src/components/atomic-crm/providers/supabase/dataProvider.ts",
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
"src/components/atomic-crm/providers/commons/getCompanyAvatar.ts",
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/deals.ts",
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
"src/components/atomic-crm/layout/Header.tsx",
"src/components/atomic-crm/layout/MobileNavigation.tsx",
"src/test/StoryWrapper.tsx",
"test-data/contacts.csv",
],
contacts: [
"src/components/atomic-crm/dataImport/useImportableResources.ts",
"src/components/atomic-crm/dataImport/useCompanyResolver.ts",
"src/components/atomic-crm/types.ts",
"src/components/atomic-crm/consts.ts",
"src/components/atomic-crm/activity/ActivityLogContactCreated.tsx",
"src/components/atomic-crm/activity/ActivityLogContactNoteCreated.tsx",
"src/components/atomic-crm/activity/ActivityLogIterator.tsx",
"src/components/atomic-crm/activity/ActivityLog.tsx",
"src/components/atomic-crm/activity/ActivityLogContext.tsx",
"src/components/atomic-crm/providers/commons/activity.ts",
"src/components/atomic-crm/dashboard/HotContacts.tsx",
"src/components/atomic-crm/dashboard/DashboardStepper.tsx",
"src/components/atomic-crm/dashboard/Dashboard.tsx",
"src/components/atomic-crm/dashboard/MobileDashboard.tsx",
"src/components/atomic-crm/dashboard/LatestNotes.tsx",
"src/components/atomic-crm/dashboard/TasksList.tsx",
"src/components/atomic-crm/companies/CompanyShow.tsx",
"src/components/atomic-crm/companies/CompanyCard.tsx",
"src/components/atomic-crm/companies/CompanyList.tsx",
"src/components/atomic-crm/notes/foreignKeyMapping.ts",
"src/components/atomic-crm/notes/NoteInputs.tsx",
"src/components/atomic-crm/notes/NoteInputs.test.tsx",
"src/components/atomic-crm/notes/NoteCreate.tsx",
"src/components/atomic-crm/notes/NotesIterator.tsx",
"src/components/atomic-crm/notes/Note.tsx",
"src/components/atomic-crm/notes/NoteAttachments.tsx",
"src/components/atomic-crm/notes/index.ts",
"src/components/atomic-crm/notes/NoteCreateSheet.tsx",
"src/components/atomic-crm/notes/NoteEditSheet.tsx",
"src/components/atomic-crm/notes/NoteShowPage.tsx",
"src/components/atomic-crm/notes/NotesIteratorMobile.tsx",
"src/components/atomic-crm/notes/NoteInputsMobile.tsx",
"src/components/atomic-crm/tasks/AddTask.tsx",
"src/components/atomic-crm/tasks/Task.tsx",
"src/components/atomic-crm/tasks/TaskCreateSheet.tsx",
"src/components/atomic-crm/tasks/TaskCreateSheet.stories.tsx",
"src/components/atomic-crm/tasks/TaskCreateSheet.test.tsx",
"src/components/atomic-crm/tasks/TaskEditSheet.tsx",
"src/components/atomic-crm/tasks/TaskFormContent.tsx",
"src/components/atomic-crm/tasks/TasksIterator.tsx",
"src/components/atomic-crm/tasks/TasksListFilter.tsx",
"src/components/atomic-crm/tasks/TasksListFilter.test.tsx",
"src/components/atomic-crm/tasks/TasksListByDueDate.tsx",
"src/components/atomic-crm/deals/DealInputs.tsx",
"src/components/atomic-crm/deals/DealShow.tsx",
"src/components/atomic-crm/deals/ContactList.tsx",
"src/components/atomic-crm/deals/DealEmpty.tsx",
"src/components/atomic-crm/misc/ContactOption.tsx",
"src/components/atomic-crm/misc/useImportFromJson.ts",
"src/components/atomic-crm/misc/ImportPage.tsx",
"src/components/atomic-crm/misc/import-sample.json",
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
"src/components/atomic-crm/providers/commons/getContactAvatar.ts",
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
"src/components/atomic-crm/providers/supabase/dataProvider.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/contactNotes.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/finalize.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/deals.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/tasks.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts",
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
"src/components/atomic-crm/layout/Header.tsx",
"src/components/atomic-crm/layout/MobileNavigation.tsx",
"src/components/atomic-crm/login/SignupPage.tsx",
"src/test/StoryWrapper.tsx",
],
deals: [
"src/components/atomic-crm/dataImport/useDealImport.ts",
"src/components/atomic-crm/dataImport/deals_sample.csv",
"src/components/atomic-crm/dataImport/useImportableResources.ts",
"src/components/atomic-crm/types.ts",
"src/components/atomic-crm/consts.ts",
"src/components/atomic-crm/dashboard/DealsChart.tsx",
"src/components/atomic-crm/dashboard/DealsPipeline.tsx",
"src/components/atomic-crm/activity/ActivityLogDealCreated.tsx",
"src/components/atomic-crm/activity/ActivityLogDealNoteCreated.tsx",
"src/components/atomic-crm/providers/fakerest/dataGenerator/deals.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/dealNotes.ts",
"src/components/atomic-crm/notes/foreignKeyMapping.ts",
"src/components/atomic-crm/notes/NoteInputs.tsx",
"src/components/atomic-crm/notes/NoteCreate.tsx",
"src/components/atomic-crm/notes/Note.tsx",
"src/components/atomic-crm/notes/NoteAttachments.tsx",
"src/components/atomic-crm/notes/NotesIterator.tsx",
"src/components/atomic-crm/notes/NoteInputs.test.tsx",
"src/components/atomic-crm/activity/ActivityLogIterator.tsx",
"src/components/atomic-crm/activity/ActivityLog.tsx",
"src/components/atomic-crm/activity/ActivityLogContext.tsx",
"src/components/atomic-crm/dashboard/Dashboard.tsx",
"src/components/atomic-crm/dashboard/LatestNotes.tsx",
"src/components/atomic-crm/providers/commons/activity.ts",
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
"src/components/atomic-crm/providers/supabase/dataProvider.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts",
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
"src/components/atomic-crm/providers/commons/i18nProvider.test.ts",
"src/components/atomic-crm/root/defaultConfiguration.ts",
"src/components/atomic-crm/root/ConfigurationContext.tsx",
"src/components/atomic-crm/settings/SettingsPage.tsx",
"src/components/atomic-crm/settings/SettingsPage.test.ts",
"src/App.tsx",
"src/components/atomic-crm/companies/CompanyShow.tsx",
"src/components/atomic-crm/companies/CompanyCard.tsx",
"src/components/atomic-crm/contacts/ContactMergeButton.tsx",
"src/components/atomic-crm/layout/Header.tsx",
"src/components/atomic-crm/layout/MobileNavigation.tsx",
"src/test/StoryWrapper.tsx",
],
tags: [
"src/components/atomic-crm/types.ts",
"src/components/atomic-crm/contacts/TagsList.tsx",
"src/components/atomic-crm/contacts/TagsListEdit.tsx",
"src/components/atomic-crm/contacts/BulkTagButton.tsx",
"src/components/atomic-crm/contacts/ContactShow.tsx",
"src/components/atomic-crm/contacts/ContactAside.tsx",
"src/components/atomic-crm/contacts/ContactListContent.tsx",
"src/components/atomic-crm/contacts/ContactList.tsx",
"src/components/atomic-crm/contacts/ContactListFilter.tsx",
"src/components/atomic-crm/contacts/contactModel.ts",
"src/components/atomic-crm/contacts/useContactImport.tsx",
"src/components/atomic-crm/companies/CompanyShow.tsx",
"src/components/atomic-crm/misc/useImportFromJson.ts",
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
"src/components/atomic-crm/contacts/ContactList.stories.tsx",
"src/components/atomic-crm/contacts/ContactList.test.tsx",
"src/test/StoryWrapper.tsx",
"src/components/atomic-crm/contacts/contacts_export.csv",
"test-data/contacts.csv",
],
tasks: [
"src/components/atomic-crm/types.ts",
"src/components/atomic-crm/dashboard/TasksList.tsx",
"src/components/atomic-crm/contacts/ContactTasksList.tsx",
"src/components/atomic-crm/providers/fakerest/dataGenerator/tasks.ts",
"src/components/atomic-crm/dashboard/Dashboard.tsx",
"src/components/atomic-crm/layout/MobileNavigation.tsx",
"src/components/atomic-crm/contacts/ContactShow.tsx",
"src/components/atomic-crm/contacts/ContactShow.test.tsx",
"src/components/atomic-crm/contacts/ContactAside.tsx",
"src/components/atomic-crm/contacts/ContactListContent.tsx",
"src/components/atomic-crm/contacts/ContactListFilter.tsx",
"src/components/atomic-crm/contacts/ContactMergeButton.tsx",
"src/components/atomic-crm/companies/CompanyShow.tsx",
"src/components/atomic-crm/misc/useImportFromJson.ts",
"src/components/atomic-crm/misc/ImportPage.tsx",
"src/components/atomic-crm/misc/import-sample.json",
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
"src/test/StoryWrapper.tsx",
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
"src/components/atomic-crm/root/defaultConfiguration.ts",
"src/components/atomic-crm/root/ConfigurationContext.tsx",
"src/components/atomic-crm/settings/SettingsPage.tsx",
"src/App.tsx",
],
} as const;
const sharedDependentFiles: string[] = [
"src/components/atomic-crm/root/CRM.tsx",
];
const resourceFilesPath = "src/components/atomic-crm" as const;
// ================================ TYPES ================================
type InitialResource = (typeof initialResource)[number];
type DependentFile = string;
type ResourceFiles = Record<InitialResource, DependentFile[]>;
// ================================ FUNCTIONS ================================
const main = async () => {
const resourcesToDelete = getResources();
const basePath = process.cwd();
for (const resource of resourcesToDelete) {
await deleteIsolatedFiles(resource, basePath);
}
returnDependentFiles(resourcesToDelete);
};
// Get the resources to delete from the command line arguments (one or more),
// validate each one, and de-duplicate.
const getResources = (): InitialResource[] => {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error(
"Please provide at least one resource to delete (you may pass several).",
);
process.exit(1);
}
const invalid = args.filter(
(resource) => !initialResource.includes(resource as InitialResource),
);
if (invalid.length > 0) {
console.error(
`Resource(s) ${invalid
.map((resource) => `"${resource}"`)
.join(", ")} do not exist. Valid resources: ${initialResource.join(
", ",
)}.`,
);
process.exit(1);
}
return [...new Set(args)] as InitialResource[];
};
// Delete the resource folder and its files.
const deleteIsolatedFiles = async (
resource: InitialResource,
basePath: string,
) => {
const folderPath = `${basePath}/${resourceFilesPath}/${resource}`;
if (!existsSync(folderPath)) {
console.error(
`Folder for resource "${resource}" does not exist. Skipping deletion of isolated files.`,
);
process.exit(1);
}
await rm(folderPath, { recursive: true, force: true });
};
// Return to Claude all the files dependent on the deleted resource(s).
// When several resources are deleted at once, merge and de-duplicate their
// dependent-file lists, then drop any file that lives inside a folder we just
// deleted (e.g. `companies/CompanyShow.tsx` is a dependent of `contacts`, but
// it is gone once `companies` is deleted too).
const returnDependentFiles = (resources: InitialResource[]) => {
const deletedFolders = resources.map(
(resource) => `${resourceFilesPath}/${resource}/`,
);
const merged = new Set<DependentFile>([
...resources.flatMap((resource) => dependentFiles[resource]),
...sharedDependentFiles,
]);
const allDependentFilesToReturn = [...merged]
.filter((file) => !deletedFolders.some((folder) => file.startsWith(folder)))
.sort();
// eslint-disable-next-line no-console
console.log(
`Dependent files for resource(s) ${resources
.map((resource) => `"${resource}"`)
.join(", ")}:\n`,
allDependentFilesToReturn,
);
process.exit(0);
};
// ================================ MAIN CALL ================================
await main();

View File

@@ -0,0 +1,14 @@
# Deleting `tags`
The simplest of the five: `tags` is a **field** (`number[]`) on `Contact` plus a small standalone `tags` folder. No shared subsystem, no config prop, no aggregated column.
## Frontend specifics
- Tag-only components under `contacts/` — delete outright: `TagsList.tsx`, `TagsListEdit.tsx`, `BulkTagButton.tsx`. Clean their consumers (`ContactShow`, `ContactAside`, `ContactListContent`, `ContactList`, `ContactListFilter`).
- The `contacts.tags` field lives in `contactModel.ts`, `useContactImport.tsx`, `providers/commons/mergeContacts.ts`, the FakeRest generators (`dataGenerator/contacts.ts` + `index.ts` + `types.ts`), the stories/tests (`ContactList.stories.tsx`, `ContactList.test.tsx`, `StoryWrapper.tsx`), and the sample CSVs (`contacts_export.csv`, `test-data/contacts.csv`).
- i18n: drop the `bulk_tag` block, `filters.tags`, and the `tags` resource block (both catalogs — see SKILL.md).
## Backend
- Drop the `tags` table + its grants/policies/sequence. `merge_contacts` in `02_functions.sql` merges `tags` — drop those lines + the now-unused local var, and renumber step comments.
## grep — benign matches to leave
- `tags` matches `stage` — filter it out (`grep -viE "stage"`).

View File

@@ -0,0 +1,13 @@
# Deleting `tasks`
`tasks` looks standalone (it owns the `tasks` folder + table) but it **adds an aggregated `nb_tasks` column to `contacts_summary`** (which lands on the `Contact` type) and ships a **`taskTypes` config prop** — so it fans out almost like a column removal.
## Frontend specifics
- **`nb_tasks` readers:** `CompanyShow.tsx`, `ContactListContent.tsx`, `ContactShow.tsx` (+ test), `ContactListFilter.tsx` (a `nb_tasks@gt` filter), `ContactMergeButton.tsx`, and the FakeRest provider/generators (which increment/decrement it). **Invisible to `\btasks?\b` grep** — grep `nb_tasks` separately.
- **`taskTypes` config prop** — see SKILL.md "Shapes a resource takes". The Tasks `<Card>` has no `validateItemsInUse`.
- **Wrapper components** outside the folder, delete outright: `dashboard/TasksList.tsx`, `contacts/ContactTasksList.tsx`.
- i18n: drop the `tasks` resource block, `task_count`, `upcoming_tasks`, `filters.tasks`, `settings.tasks`, and the import description (both catalogs — see SKILL.md).
## Backend
- Drop the `tasks` table + grants/policies/sequence.
- **MCP edge function:** `mcp/taskListUi.ts` is a ~13 KB guest-HTML "MCP App" — delete outright. Remove the `display_task_list` + `complete_task` tools and their prompt examples in `mcp/index.ts`, and the `tasks` example in `mcp/validateSql.test.ts`. (Note: the `CRM_BASE_URL` env var is driven by the task→contact deep-link, which is really a `contacts` concern — see [`contacts.md`](contacts.md); it only goes dead if contacts is deleted too.)

View File

@@ -0,0 +1,48 @@
---
name: e2e-conventions
description: When to write e2e tests, where to put them, and how to verify them. Apply to any task touching UI, filters, forms, or interactions.
---
# E2E Conventions
Reference for *whether* an e2e test is required, *where* it goes, and *what* it must assert. For the assertion/locator patterns inside the spec, see
`Skill({skill: "playwright-testing"})`.
## When e2e tests are required
A change **requires** an e2e test if it touches any of:
- UI components or pages
- Filters or search
- Forms or user input
- Interactions (click, drag, keyboard)
Exception: pure CSS or a DB-migration-only change. State this explicitly in the task notes.
## Where to put them
Under `e2e/`, named after the feature:
```
e2e/<feature-name>.spec.ts
```
If the work is tracked under a ticket id, you may prefix the file with it (e.g. `e2e/TASK-001-deal-importance.spec.ts`) to make it easy to find — but the ticket id is not required.
## What to verify
Write the spec alongside the implementation. The reviewer checks that the spec exists and asserts the right thing. CI runs it — don't run it locally yourself.
## Red Flags
- A UI/filter/form/interaction change with no `e2e/*.spec.ts` added.
- Claiming the CSS/migration-only exception without saying so in the task notes.
- A spec placed outside `e2e/`, or that asserts nothing user-visible.
- Running the e2e suite locally instead of letting CI do it.
## Verification
- [ ] If the change touches UI/filters/forms/interactions, a spec exists under `e2e/`.
- [ ] The spec is named after the feature (ticket-id prefix optional).
- [ ] The spec asserts the right user-visible behavior, using `playwright-testing` patterns.
- [ ] Any CSS/migration-only exemption is stated explicitly in the task notes.

View File

@@ -0,0 +1,79 @@
---
name: frontend-dev
description: Coding practices for frontend development in Atomic CRM. Use when creating or modifying React components, forms, list pages, detail views, filters, data fetching, or responsive layouts.
---
# Frontend Development
Reference for the conventions a frontend change must follow.
This is a reference skill, not a step sequence — read the relevant section before writing code, then verify against the checklist at the end.
The frontend uses ra-core (react-admin headless) for data fetching, routing, and CRUD logic, with shadcn-admin-kit and shadcn/ui for the UI layer.
## When to Use
- Creating or modifying React components, forms, list pages, or detail views.
- Adding or changing filters, data fetching, or responsive (mobile/desktop) layouts.
- Registering a new resource or reorganizing an existing resource folder.
Not for backend/schema work — see `Skill({skill: "backend-dev"})`.
Not for theming/colors — see `Skill({skill: "shadcn-customization"})`.
## Component architecture
- Import form inputs (`TextInput`, `SelectInput`, `ReferenceInput`, etc.) from `@/components/admin/`, not from shadcn/ui directly. The admin layer wraps shadcn with ra-core integration (labels, validation, data binding).
- Import pure UI components (`Card`, `Button`, `Badge`, `Sheet`, etc.) from `@/components/ui/`.
- Domain configuration (deal stages, note statuses, task types, company sectors) comes from `useConfigurationContext()`, never hardcoded.
## Resource (CRUD) conventions
Each resource follows this file structure (e.g. `contacts/`):
- `ContactList.tsx` — list page (desktop + mobile variants)
- `ContactShow.tsx` — detail view
- `ContactEdit.tsx` / `ContactCreate.tsx` — form pages
- `ContactInputs.tsx` — shared form fields reused between create and edit
- `index.tsx` — exports `{ list, show, edit, create, recordRepresentation }`
Resources are registered in `root/CRM.tsx` via `<Resource name="contacts" {...contacts} />`.
## Data fetching
- For standard CRUD, use ra-core hooks: `useListContext()`, `useShowContext()`, `useGetList()`, `useGetOne()`, `useGetIdentity()`.
- When a query or mutation isn't covered by ra-core hooks, add a custom dataProvider method and call it via `useQuery`/`useMutation` with `useDataProvider<CrmDataProvider>()` (e.g. `dataProvider.getActivityLog()` in `ActivityLog.tsx`, `dataProvider.salesCreate()` in `SalesCreate.tsx`).
## Forms
- Forms use `Form` from ra-core + `FormToolbar` for submit/cancel actions.
- Ra-core's `Form` uses React Hook Form under the hood. Use `useFormContext()` for imperative operations (`setValue`, `reset`, `getValues`).
- Top-level resource forms use full-page `CreateBase`/`EditBase` with `Card` (e.g. contacts), or `Dialog` (e.g. deals).
- On mobile, inline/sub-resource forms use `CreateSheet`/`EditSheet` from `misc/` (e.g. notes, tasks).
- Split form fields into semantic sub-components (e.g. `ContactIdentityInputs`, `ContactPositionInputs`).
## Filters
- Use `ToggleFilterButton` / `ActiveFilterButton` components for filter UI.
- Filters apply immediately, no "apply" button.
## Responsive design
- Major pages have desktop and mobile variants. Use `useIsMobile()` to branch.
- Desktop: 2-column grid layouts. Mobile: single column with `MobileHeader`/`MobileContent`.
- Mobile lists use `InfiniteListBase` for scroll pagination.
## Red Flags
- Importing `TextInput`/`SelectInput`/etc. directly from `@/components/ui/` instead of `@/components/admin/`.
- Hardcoding deal stages, note statuses, task types, or sectors instead of reading `useConfigurationContext()`.
- A new resource folder missing one of the standard files, or not registered in `root/CRM.tsx`.
- A bespoke fetch/axios call where a ra-core hook or a dataProvider method belongs.
- A new top-level page with only a desktop layout and no `useIsMobile()` branch.
- A form field component growing past the typical size instead of being split into semantic sub-components.
## Verification
- [ ] Inputs imported from `@/components/admin/`, pure UI from `@/components/ui/`.
- [ ] Domain options come from `useConfigurationContext()` — nothing hardcoded.
- [ ] New resources follow the file structure and are registered in `root/CRM.tsx`.
- [ ] Data access uses ra-core hooks, or a dataProvider method via react-query.
- [ ] Pages touching major UI have both desktop and mobile variants.
- [ ] If the change touches UI/forms/filters/interactions, an e2e test exists (see `Skill({skill: "e2e-conventions"})`).

View File

@@ -0,0 +1,38 @@
---
name: grill-me
description: Interrogate a vague or broad feature request BEFORE dispatching the harness orchestrator, to surface what the user has not anticipated. Use in the main thread when a #harness request is underspecified, when scope seems broad, or when a plan would come out too generic. Not for the orchestrator (headless, cannot ask).
---
# grill-me
Ask probing questions about the request before any plan or code, to surface
what the user has NOT anticipated, not just confirm what they already said.
## How
- **One question at a time**, in prose. Never a multiple-choice menu, never a
batch of five at once.
- Read the answer, then ask the next most valuable question given it.
- **Stop as soon as the scope is answerable** (roughly: you could write the
orchestrator dispatch prompt without guessing). Do not interrogate for its
own sake.
## Cover, at minimum
1. **Scope-out**: what is explicitly NOT in scope? What might look included but isn't?
2. **Edge cases**: empty / invalid input, network or backend error, two users at once.
3. **Existing interactions**: does this touch behavior already in place (permissions,
RLS, cached data, shared views, another resource)?
4. **Definition of done**: what manual check confirms it works? Who validates before merge?
5. **Reversibility**: if it turns out wrong, how hard is it to undo?
If the user answers "I don't know" to a structural question, propose an explicit
hypothesis and ask them to confirm rather than silently choosing.
## After grilling
Fold the answers into the orchestrator dispatch prompt (scope-out and
acceptance become ticket constraints). Then dispatch the harness as usual.
The orchestrator never invokes this skill: it runs headless and cannot hold an
interactive back-and-forth. Grilling is a main-thread, pre-dispatch step.

View File

@@ -0,0 +1,478 @@
---
name: playwright-testing
description: Playwright E2E testing patterns — web-first assertions, user-visible locators, network interception, fixtures, authentication, and parallel execution. Use when building or reviewing E2E tests with Playwright, when setting up browser testing for a web app, or when migrating from Cypress or Selenium.
---
# Playwright E2E Testing
Reference for the assertion, locator, and structure patterns used inside an e2e spec. Browser-based end-to-end testing with auto-waiting, cross-browser support, and reliable locators.
## When to Use
- Writing or reviewing the body of a Playwright e2e spec.
- Setting up browser testing for the app (config, fixtures, auth, parallelism).
- Migrating tests from Cypress or Selenium.
For *whether* a change requires an e2e test and *where* the spec goes, see
`Skill({skill: "e2e-conventions"})`.
---
## 1. Web-First Assertions (Not Manual Checks)
Playwright's `expect(locator)` assertions **auto-retry** until the condition is met or the timeout expires. Never use `page.$()`, `page.evaluate()`, or manual boolean checks to verify element state.
### WRONG — manual element checks
```typescript
// Returns null immediately if element doesn't exist yet — race condition!
const el = await page.$('.order-confirmation');
expect(el).not.toBeNull();
// Evaluates once, no retry — flaky on slow renders
const text = await page.textContent('.status');
expect(text).toBe('Order received');
// Manual waitForSelector + isVisible — redundant, ignores auto-retry
await page.waitForSelector('.status');
const visible = await page.locator('.status').isVisible();
expect(visible).toBe(true);
```
### RIGHT — web-first assertions that auto-retry
```typescript
// Auto-retries until element is visible or timeout
await expect(page.getByText('Order received')).toBeVisible();
// Auto-retries until text matches
await expect(page.getByTestId('status')).toHaveText('Order received');
// Auto-retries for element count
await expect(page.getByRole('listitem')).toHaveCount(3);
// Auto-retries for attribute values
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
```
**Why it matters:** `page.$()` and `page.textContent()` execute once and return immediately. If the UI hasn't rendered yet, the test fails intermittently. Web-first assertions (`expect(locator).toBeVisible()`, `.toHaveText()`, `.toHaveCount()`) retry automatically until the assertion passes or the timeout expires, making tests reliable without explicit waits.
---
## 2. Locator Strategy: User-Visible Locators Over CSS
Prefer locators that match what the user sees. CSS selectors break when class names, IDs, or DOM structure change.
### WRONG — CSS/XPath selectors
```typescript
page.locator('.btn-primary.submit-order');
page.locator('#order-form > div:nth-child(3) > button');
page.locator('//button[@class="submit"]');
```
### RIGHT — user-visible locators (priority order)
```typescript
// 1. BEST — role-based (matches accessible name)
page.getByRole('button', { name: 'Place Order' });
page.getByRole('heading', { name: /checkout/i });
page.getByRole('link', { name: 'View cart' });
// 2. GOOD — label, placeholder, text
page.getByLabel('Email address');
page.getByPlaceholder('Search products');
page.getByText('Order #42');
// 3. ACCEPTABLE — test IDs (for elements without accessible names)
page.getByTestId('order-status-badge');
```
**Why it matters:** `getByRole` tests your app the way a user (and a screen reader) interacts with it. CSS selectors like `.btn-primary` break on refactors. Test IDs are stable but don't validate accessibility — use them only as a fallback.
---
## 3. No Explicit Waits — Use Locator Assertions
Never use `page.waitForTimeout()`, `page.waitForSelector()`, or `setTimeout`. Playwright auto-waits on actions and web-first assertions.
### WRONG — explicit waits
```typescript
// Arbitrary timeout — slow and still flaky
await page.waitForTimeout(3000);
await page.click('.submit-btn');
// waitForSelector is redundant before a locator action
await page.waitForSelector('.product-card');
await page.locator('.product-card').first().click();
```
### RIGHT — rely on auto-waiting
```typescript
// Playwright auto-waits for the button to be actionable before clicking
await page.getByRole('button', { name: 'Submit' }).click();
// Web-first assertion auto-retries until products appear
await expect(page.getByRole('listitem')).toHaveCount(5);
await page.getByRole('listitem').first().click();
```
**Why it matters:** `waitForTimeout(3000)` is the #1 cause of slow test suites. Playwright already waits for elements to be visible, stable, and enabled before performing actions. Adding explicit waits is redundant and makes tests slower without adding reliability.
---
## 4. Network Interception with page.route()
Use `page.route()` to mock API responses for isolated, deterministic tests. Use `page.waitForResponse()` to assert on real API calls.
### Mocking API responses
```typescript
test('shows error when API fails', async ({ page }) => {
await page.route('**/api/products', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
})
);
await page.goto('/');
await expect(page.getByRole('alert')).toContainText(/error/i);
});
test('displays products from mocked API', async ({ page }) => {
await page.route('**/api/products', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 19.99 },
]),
})
);
await page.goto('/');
await expect(page.getByRole('listitem')).toHaveCount(2);
});
```
### Waiting for real API calls
```typescript
test('order submission calls the API', async ({ page }) => {
await page.goto('/checkout');
await page.getByLabel('Name').fill('Jane Doe');
// Set up the response promise BEFORE triggering the action
const responsePromise = page.waitForResponse('**/api/orders');
await page.getByRole('button', { name: 'Place Order' }).click();
const response = await responsePromise;
expect(response.status()).toBe(201);
});
```
**Why it matters:** `page.route()` intercepts at the network level, so your UI code runs exactly as in production but with controlled data. Always set up `waitForResponse` **before** the action that triggers it to avoid race conditions.
---
## 5. Test Structure: test.describe and test.beforeEach
### WRONG — flat tests with repeated setup
```typescript
test('shows products', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /products/i })).toBeVisible();
});
test('can add to cart', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /add/i }).first().click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
```
### RIGHT — grouped tests with shared setup
```typescript
import { test, expect } from '@playwright/test';
test.describe('Product catalog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('displays product listing', async ({ page }) => {
await expect(page.getByRole('heading', { name: /products/i })).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(5);
});
test('can add item to cart', async ({ page }) => {
await page.getByRole('button', { name: /add/i }).first().click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('can search products', async ({ page }) => {
await page.getByPlaceholder('Search').fill('widget');
await expect(page.getByRole('listitem')).toHaveCount(1);
});
});
```
**Why it matters:** `test.describe` groups related tests for readability and lets you share setup via `test.beforeEach`. Tests within a describe can also share hooks, annotations (like `test.slow()`), and configuration overrides.
---
## 6. Fixtures for Setup and Teardown
Use Playwright's fixture system to share setup logic across tests. Custom fixtures replace manual `beforeEach`/`afterEach` patterns and are composable.
### WRONG — manual setup in every test
```typescript
test('admin can manage users', async ({ page }) => {
// Repeated in every admin test
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Actual test logic
await page.goto('/admin/users');
await expect(page.getByRole('table')).toBeVisible();
});
```
### RIGHT — custom fixture
```typescript
// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';
type Fixtures = {
adminPage: import('@playwright/test').Page;
};
export const test = base.extend<Fixtures>({
adminPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
// Teardown runs automatically after test
},
});
export { expect };
// e2e/admin.spec.ts
import { test, expect } from './fixtures';
test('admin can manage users', async ({ adminPage }) => {
await adminPage.goto('/admin/users');
await expect(adminPage.getByRole('table')).toBeVisible();
});
```
---
## 7. Authentication with storageState
For apps requiring login, authenticate once in a setup project and reuse the session via `storageState`. This avoids logging in before every test.
### playwright.config.ts — setup project
```typescript
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { storageState: 'e2e/.auth/user.json' },
},
],
});
```
### e2e/auth.setup.ts — run once
```typescript
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});
```
**Why it matters:** Without `storageState`, every test file repeats the login flow, which is slow and fragile. The setup project runs once, saves cookies/localStorage to a JSON file, and all dependent tests start already authenticated.
---
## 8. Page Object Model
Encapsulate page interactions in classes to reduce duplication and make tests resilient to UI changes.
```typescript
// e2e/pages/checkout.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class CheckoutPage {
readonly nameInput: Locator;
readonly emailInput: Locator;
readonly submitButton: Locator;
readonly confirmationMessage: Locator;
constructor(private page: Page) {
this.nameInput = page.getByLabel('Full name');
this.emailInput = page.getByLabel('Email');
this.submitButton = page.getByRole('button', { name: 'Place Order' });
this.confirmationMessage = page.getByText(/order.*confirmed/i);
}
async goto() {
await this.page.goto('/checkout');
}
async fillAndSubmit(name: string, email: string) {
await this.nameInput.fill(name);
await this.emailInput.fill(email);
await this.submitButton.click();
}
async expectConfirmation() {
await expect(this.confirmationMessage).toBeVisible();
}
}
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { CheckoutPage } from './pages/checkout.page';
test('completes checkout', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.goto();
await checkout.fillAndSubmit('Jane Doe', 'jane@example.com');
await checkout.expectConfirmation();
});
```
**Why it matters:** When the UI changes (button text, label wording), you update one page object instead of every test file. Page objects also keep test bodies focused on the user scenario, not DOM details.
---
## 9. Configuration
```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
```
### Key config settings
- **`fullyParallel: true`** — runs tests in parallel by default (Playwright's default). Do not disable unless tests share mutable state.
- **`forbidOnly: !!process.env.CI`** — fails CI if `test.only` is left in code.
- **`webServer`** — auto-starts your dev server before tests.
- **`trace: 'on-first-retry'`** — captures traces on first retry for debugging via `npx playwright show-trace`.
- **`screenshot: 'only-on-failure'`** — captures screenshots only when tests fail.
---
## 10. Visual Regression Testing
```typescript
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
test('product card renders correctly', async ({ page }) => {
await page.goto('/products');
await expect(page.getByTestId('product-card').first()).toHaveScreenshot('product-card.png', {
maxDiffPixels: 100,
});
});
```
Run `npx playwright test --update-snapshots` to generate baseline images. Snapshots are stored per-project (browser) and per-platform.
---
## References
- [Playwright Docs: Locators](https://playwright.dev/docs/locators)
- [Playwright Docs: Assertions](https://playwright.dev/docs/test-assertions)
- [Playwright Docs: Auto-waiting](https://playwright.dev/docs/actionability)
- [Playwright Docs: Network](https://playwright.dev/docs/network)
- [Playwright Docs: Authentication](https://playwright.dev/docs/auth)
- [Playwright Docs: Fixtures](https://playwright.dev/docs/test-fixtures)
- [Playwright Docs: Page Object Model](https://playwright.dev/docs/pom)
---
## Red Flags
- `page.$()` / `page.textContent()` / `isVisible()` checks instead of web-first assertions.
- CSS or XPath selectors where a `getByRole`/`getByLabel`/`getByText` locator exists.
- Any `waitForTimeout()`, or `waitForSelector()` before a locator action.
- `waitForResponse()` set up *after* the action that triggers the call.
- Login flow repeated per test instead of `storageState` or a fixture.
- `test.only` left in code, or `fullyParallel` disabled without a shared-state reason.
## Verification
- [ ] Web-first assertions (`expect(locator).toBeVisible()`) — never `page.$()` or `page.textContent()`
- [ ] User-visible locators (`getByRole`, `getByLabel`, `getByText`) over CSS selectors
- [ ] No explicit waits (`waitForTimeout`, `waitForSelector` before actions)
- [ ] `test.describe` groups related tests; `test.beforeEach` for shared setup
- [ ] `page.route()` for API mocking in isolated tests
- [ ] `page.waitForResponse()` set up before the triggering action
- [ ] `webServer` configured to start app automatically
- [ ] `fullyParallel: true` for parallel execution
- [ ] `forbidOnly: !!process.env.CI` to prevent `test.only` in CI
- [ ] Screenshots on failure (`screenshot: 'only-on-failure'`)
- [ ] Traces on retry (`trace: 'on-first-retry'`)
- [ ] Retries in CI (`retries: process.env.CI ? 2 : 0`)
- [ ] `storageState` for authentication reuse across tests
- [ ] Page objects encapsulate locators and actions for reusable pages

View File

@@ -0,0 +1,40 @@
---
name: ponytail-audit
description: >
Whole-repo audit for over-engineering. Like ponytail-review, but scans the
entire codebase instead of a diff: a ranked list of what to delete, simplify,
or replace with stdlib/native equivalents. Use when the user says "audit this
codebase", "audit for over-engineering", "what can I delete from this repo",
"find bloat", "ponytail-audit", or "/ponytail-audit". One-shot report, does
not apply fixes.
---
ponytail-review, repo-wide. Scan the whole tree instead of a diff. Rank
findings biggest cut first.
## Tags
Same as ponytail-review:
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
- `native:` dependency or code doing what the platform already does. Name the feature.
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
- `shrink:` same logic, fewer lines. Show the shorter form.
## Hunt
Deps the stdlib or platform already ships, single-implementation interfaces,
factories with one product, wrappers that only delegate, files exporting one
thing, dead flags and config, hand-rolled stdlib.
## Output
One line per finding, ranked: `<tag> <what to cut>. <replacement>. [path]`.
End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. Ship.`
## Boundaries
Complexity only, correctness bugs, security holes, and performance go to a
normal review pass. Lists findings, applies nothing. One-shot.
"stop ponytail-audit" or "normal mode" to revert.

View File

@@ -0,0 +1,44 @@
---
name: ponytail-debt
description: >
Harvest every `ponytail:` comment in the codebase into a debt ledger, so the
deliberate shortcuts and deferrals ponytail leaves behind get tracked instead
of rotting into "later means never". Use when the user says "ponytail debt",
"/ponytail-debt", "what did ponytail defer", "list the shortcuts", "ponytail
ledger", or "what did we mark to do later". One-shot report, changes nothing.
---
Every deliberate ponytail shortcut is marked with a `ponytail:` comment naming
its ceiling and upgrade path. This collects them into one ledger so a deferral
can't quietly become permanent.
## Scan
Grep the repo for comment markers, skipping `node_modules`, `.git`, and build
output:
`grep -rnE '(#|//) ?ponytail:' .` (add other comment prefixes if your stack uses them)
Each hit is one ledger row. The comment prefix keeps prose that merely mentions
the convention out of the ledger.
## Output
One row per marker, grouped by file:
`<file>:<line> — <what was simplified>. ceiling: <the limit named>. upgrade: <the trigger to revisit>.`
The convention is `ponytail: <ceiling>, <upgrade path>`, so pull the ceiling
and the trigger straight from the comment. Want an owner per row too? add
`git blame -L<line>,<line>`.
Flag the rot risk: any `ponytail:` comment that names no upgrade path or
trigger gets a `no-trigger` tag, those are the ones that silently rot.
End with `<N> markers, <M> with no trigger.` Nothing found: `No ponytail: debt. Clean ledger.`
## Boundaries
Reads and reports only, changes nothing. To persist it, ask and it writes the
ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. "stop ponytail-debt" or
"normal mode" to revert.

View File

@@ -0,0 +1,68 @@
---
name: ponytail-help
description: >
Quick-reference card for all ponytail modes, skills, and commands.
One-shot display, not a persistent mode. Trigger: /ponytail-help,
"ponytail help", "what ponytail commands", "how do I use ponytail".
---
# Ponytail Help
Display this reference card when invoked. One-shot, do NOT change mode,
write flag files, or persist anything.
## Levels
| Level | Trigger | What change |
|-------|---------|-------------|
| **Lite** | `/ponytail lite` | Build what's asked, name the lazier alternative in one line. |
| **Full** | `/ponytail` | The ladder enforced: YAGNI → stdlib → native → one line → minimum. Default. |
| **Ultra** | `/ponytail ultra` | YAGNI extremist. Deletion before addition. Challenges requirements before building. |
Level sticks until changed or session end.
## Skills
| Skill | Trigger | What it does |
|-------|---------|--------------|
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
| **ponytail-help** | `/ponytail-help` | This card. |
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and
`/ponytail-review`).
## Deactivate
Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
`/ponytail off` also works.
## Configure Default Mode
Default mode = `full`, auto-active every session. Change it:
**Environment variable** (highest priority):
```bash
export PONYTAIL_DEFAULT_MODE=ultra
```
**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`):
```json
{ "defaultMode": "lite" }
```
Set `"off"` to disable auto-activation on session start, activate manually
with `/ponytail` when wanted.
Resolution: env var > config file > `full`.
## Update
Enable auto-update once: open `/plugin`, go to Marketplaces, pick ponytail, Enable auto-update. Claude Code then pulls new versions at startup (run `/reload-plugins` when it prompts). Manual refresh: `/plugin marketplace update ponytail` then `/reload-plugins`.
If `/plugin` is not recognized, your Claude Code is out of date. Update it (`npm install -g @anthropic-ai/claude-code@latest`, or `brew upgrade claude-code`) and restart. Other hosts use their own update flow.
## More
Full docs + examples: https://github.com/DietrichGebert/ponytail

View File

@@ -0,0 +1,56 @@
---
name: ponytail-review
description: >
Code review focused exclusively on over-engineering. Finds what to delete:
reinvented standard library, unneeded dependencies, speculative abstractions,
dead flexibility. One line per finding: location, what to cut, what replaces
it. Use when the user says "review for over-engineering", "what can we
delete", "is this over-engineered", "simplify review", or invokes
/ponytail-review. Complements correctness-focused review, this one only
hunts complexity.
---
Review diffs for unnecessary complexity. One line per finding: location, what
to cut, what replaces it. The diff's best outcome is getting shorter.
## Format
`L<line>: <tag> <what>. <replacement>.`, or `<file>:L<line>: ...` for
multi-file diffs.
Tags:
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
- `native:` dependency or code doing what the platform already does. Name the feature.
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
- `shrink:` same logic, fewer lines. Show the shorter form.
## Examples
❌ "This EmailValidator class might be more complex than necessary, have you
considered whether all these validation rules are needed at this stage?"
✅ `L12-38: stdlib: 27-line validator class. "@" in email, 1 line, real validation is the confirmation mail.`
✅ `L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.`
✅ `repo.py:L88: yagni: AbstractRepository with one implementation. Inline it until a second one exists.`
✅ `L52-71: delete: retry wrapper around an idempotent local call. Nothing replaces it.`
✅ `L30-44: shrink: manual loop builds dict. dict(zip(keys, values)), 1 line.`
## Scoring
End with the only metric that matters: `net: -<N> lines possible.`
If there is nothing to cut, say `Lean already. Ship.` and stop.
## Boundaries
Complexity only, correctness bugs, security holes, and performance go to a
normal review pass, not this one. A single smoke test or `assert`-based
self-check is the ponytail minimum, not bloat, never flag it for deletion.
Does not apply the fixes, only lists them.
"stop ponytail-review" or "normal mode": revert to verbose review style.

View File

@@ -0,0 +1,100 @@
---
name: ponytail
description: >
Forces the laziest solution that actually works, simplest, shortest, most
minimal. Channels a senior dev who has seen everything: question whether the
task needs to exist at all (YAGNI), reach for the standard library before
custom code, native platform features before dependencies, one line before
fifty. Supports intensity levels: lite, full (default), ultra. Use whenever
the user says "ponytail", "be lazy", "lazy mode", "simplest solution",
"minimal solution", "yagni", "do less", or "shortest path", and whenever
they complain about over-engineering, bloat, boilerplate, or unnecessary
dependencies.
license: MIT
---
# Ponytail
You are a lazy senior developer. Lazy means efficient, not careless. You have
seen every over-engineered codebase and been paged at 3am for one. The best
code is the code never written.
## Persistence
ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if
unsure. Off only: "stop ponytail" / "normal mode". Default: **full**.
Switch: `/ponytail lite|full|ultra`.
## The ladder
Stop at the first rung that holds:
1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
2. **Stdlib does it?** Use it.
3. **Native platform feature covers it?** `<input type="date">` over a picker lib, CSS over JS, DB constraint over app code.
4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do.
5. **Can it be one line?** One line.
6. **Only then:** the minimum code that works.
The ladder is a reflex, not a research project. Two rungs work → take the
higher one and move on. The first lazy solution that works is the right one.
## Rules
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
- No boilerplate, no scaffolding "for later", later can scaffold for itself.
- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
- Fewest files possible. Shortest working diff wins.
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
## Output
Code first. Then at most three short lines: what was skipped, when to add it.
No essays, no feature tours, no design notes. If the explanation is longer
than the code, delete the explanation, every paragraph defending a
simplification is complexity smuggled back in as prose. Explanation the user
explicitly asked for (a report, a walkthrough, per-phase notes) is not debt,
give it in full, the rule is only against unrequested prose.
Pattern: `[code] → skipped: [X], add when [Y].`
## Intensity
| Level | What change |
|-------|------------|
| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. |
| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
Example: "Add a cache for these API responses."
- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
## When NOT to be lazy
Never simplify away: input validation at trust boundaries, error handling
that prevents data loss, security measures, accessibility basics, anything
explicitly requested. User insists on the full version → build it, no
re-arguing.
Hardware is never the ideal on paper: a real clock drifts, a real sensor
reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not
just less code, the physical world needs tuning a minimal model can't see.
Lazy code without its check is unfinished. Non-trivial logic (a branch, a
loop, a parser, a money/security path) leaves ONE runnable check behind, the
smallest thing that fails if the logic breaks: an `assert`-based
`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no
fixtures, no per-function suites unless asked. Trivial one-liners need no
test, YAGNI applies to tests too.
## Boundaries
Ponytail governs what you build, not how you talk (pair with Caveman for
terse prose). "stop ponytail" / "normal mode": revert. Level persists until
changed or session end.
The shortest path to done is the right path.

View File

@@ -0,0 +1,158 @@
---
name: resolving-rollback-conflicts
description: Replay a list of git-revert commits against the current base branch and resolve the conflicts, when the chat-service's automatic rollback hit a merge conflict it couldn't resolve. Load this when your developer dispatch asks you to resolve a rollback conflict (it carries BASE_BRANCH, FAILED_COMMIT, COMMITS_TO_REVERT). This is NOT a feature ticket — the workflow below replaces the normal ticket rules.
---
# Resolving rollback conflicts
The chat-service's HTTP `/rollback` route attempted to `git revert -m 1 <sha>` each merge commit on the base branch, and one of them conflicted. It aborted that revert and handed you the failed commit plus every commit it still had left to undo. Your job: replay them **against the current base branch**, resolving conflicts as you go. You are dispatched **alone** — no team, no peers. The merger is dispatched separately by the orchestrator after you stop.
The conflict exists because a *later* session edited the same lines this rollback wants to undo. That conflict only shows up against the current base branch — not against the stale `session/<SESSION_SHORT_ID>` your worktree was forked from. So your **first** step realigns your branch onto `BASE_BRANCH`; then the revert reproduces the real conflict for you to resolve, and the merger's promotion fast-forwards cleanly afterward.
## Spawn prompt — what you receive
```
ROLE: developer
WORKTREE_PATH: <WORKTREE_BASE>/simple
BRANCH_NAME: <SESSION_SHORT_ID>/simple
BASE_BRANCH: <the default branch, e.g. master — the promotion target>
FAILED_COMMIT: <short sha> ("<subject>")
COMMITS_TO_REVERT:
- <sha> # <subject>
- ...
```
**Working directory is `<WORKTREE_PATH>`** — the shared `<base>/simple` worktree the `setup-worktree` hook creates for you. Every Bash call must `cd <WORKTREE_PATH> && …` (shell state is stateless between calls). Do NOT touch `$CLAUDE_PROJECT_DIR/src/...` — that's the base branch.
## Output contract
Your very last line of output MUST be exactly one of:
- `DONE: branch=<SESSION_SHORT_ID>/simple files=[<every file you touched, deduped>]`
- `FAILED: <one-line, plain-English reason — say what was confusing>`
The orchestrator's STATE RB-MERGE relies on those literal prefixes.
## Workflow
### Step 0 — Realign your branch onto the base branch (do this ONCE, first)
Your worktree was forked from the stale `session/<SESSION_SHORT_ID>`, where the conflicting later changes don't exist — revert there and it applies cleanly but the conflict resurfaces, unresolved, at promotion. Reset your branch onto the current base branch so you replay the reverts against the exact state the HTTP route hit the conflict on:
```bash
cd <WORKTREE_PATH> && git reset --hard <BASE_BRANCH>
```
This is the only branch-moving command you may run, and only here. After it, proceed to the loop below.
Then, for each SHA in `COMMITS_TO_REVERT`, in the order given:
### Step 1 — Read the commit you're about to undo
Before touching anything, get the canonical record of what this commit changed. This is the **ground truth** you'll use to resolve conflicts and to interpret empty reverts:
```bash
cd <WORKTREE_PATH> && git show --stat <sha> # which files, how big
cd <WORKTREE_PATH> && git show <sha> # full diff
```
Read the diff. Identify, in your head, **exactly** what this commit added (the `+` lines on the "after" side) and what it replaced (the `-` lines). The revert's job is to remove the `+` and put back the `-` — nothing else.
### Step 2 — Attempt the revert
```bash
cd <WORKTREE_PATH> && git revert --no-edit -m 1 <sha>
```
Three possible outcomes — match yours below.
### Outcome A — Clean revert with real changes
`git revert` exited 0 AND `git diff --name-only HEAD^ HEAD` is non-empty. Sanity-check: do the changed files / changed lines match what Step 1 told you to expect? If yes, go to next SHA.
### Outcome B — Empty revert (clean exit, zero changes)
`git revert` exited 0 BUT `git diff --name-only HEAD^ HEAD` is empty.
This means a later commit on the base branch has **already removed or transformed** the lines your target commit added. Two sub-cases:
- **B1 — Pure substitution** (e.g. target said `X → Y`, current main says `Z`): the user's intent — "undo X → Y" — can sometimes still be expressed as `Z → X`. Look at Step 1's `+` strings; grep current main for them; if they're absent but the file at the same location now has a different value, that's the later commit's overwrite. Edit the file to replace that later value with the original `-` strings from Step 1. Then commit **with the revert marker** so a future rollback knows this commit was already undone:
```bash
cd <WORKTREE_PATH> && git add -A && git commit -m "revert: semantic revert of <sha-short>" -m "This reverts commit <FULL 40-char sha>."
```
The second `-m` line is mandatory: the chat-service detects already-reverted commits by scanning for `This reverts commit <sha>`. Native `git revert` / `git revert --continue` write this marker automatically; a manual semantic revert must add it explicitly or the commit will be re-reverted on the next rollback. Re-check with `git diff --name-only HEAD^ HEAD` — it should be non-empty now.
- **B2 — Target already fully absent** (the later commit removed the addition entirely, no equivalent value to swap): the target commit's effect is already gone from main. Nothing to revert.
```bash
cd <WORKTREE_PATH> && git reset --hard HEAD^
```
```
FAILED: revert of <sha> produced no changes — its additions have already been removed by a later commit
```
If you're unsure which sub-case applies, prefer B2 (FAILED) — a confusing-but-honest failure is better than a hallucinated edit that touches files the user didn't expect.
### Outcome C — Conflict (non-zero exit, unmerged paths)
`git status` shows `You are currently reverting commit <sha>` plus `UU`/`AA`/`DU`/`UD` entries.
```bash
cd <WORKTREE_PATH> && git status --porcelain
cd <WORKTREE_PATH> && git diff --diff-filter=U
```
For each conflict file, the markers look like:
```
<<<<<<< HEAD
<current state on the base branch — includes whatever later commits added>
=======
<state after applying the revert — the target commit's additions are removed,
but later commits' additions on the SAME lines might also be missing here>
>>>>>>> parent of <sha>...
```
**Goal**: produce a version where the target commit's `+` lines (from Step 1) are removed, but **every** later commit's contribution that you can identify is preserved.
Heuristic, in order:
1. **Read both sides side-by-side**. Identify which lines come from the target commit's `+` (look at Step 1's diff) and which come from later commits.
2. **Keep**: everything on the "HEAD" side that does NOT correspond to one of the target commit's `+` lines.
3. **Drop**: the target commit's `+` lines (and only those).
4. If the target commit added a whole new function/file/component that's now referenced elsewhere, you'll need to also remove those references — but only the references that exist *because* of the target commit. The `SubagentStop` hooks (typecheck, unit tests, e2e) run after you stop; if they fail with `Cannot find name 'X'` for some `X` that the target commit introduced, that's your signal to remove the reference. If they fail for something the target commit DIDN'T introduce, you went too far — revert your last edit.
After resolving every conflict file:
```bash
cd <WORKTREE_PATH> && git add -A && git revert --continue --no-edit
```
Then re-check `git diff --name-only HEAD^ HEAD`. If empty, apply the Outcome B logic. Otherwise, go to next SHA.
### Step 3 — All SHAs processed
Emit the output contract:
```
DONE: branch=<SESSION_SHORT_ID>/simple files=[<every file you touched, deduped>]
```
The orchestrator's STATE RB-MERGE will dispatch the single-shot merger to merge your branch back into the base.
### Step 4 — Unrecoverable failure
If at any point you can't make progress (a conflict you genuinely can't read, three rounds of validation hook failures with no fix in sight, etc.):
```bash
cd <WORKTREE_PATH> && git revert --abort 2>/dev/null || true
```
```
FAILED: rollback merge failed: <one-line, plain-English reason — say what was confusing>
```
## NEVER while resolving a rollback conflict
- ❌ Run `git merge`, `git push`, `git checkout`, `--no-verify`. The only branch-moving commands allowed are: `git reset --hard <BASE_BRANCH>` exactly once at Step 0, and `git reset --hard HEAD^` to undo your own empty revert as documented in Outcome B.
- ❌ Edit anything outside `<WORKTREE_PATH>/`. Never edit `$CLAUDE_PROJECT_DIR/...` directly, never edit `.git/` internals.
- ❌ Drive-by refactors, prettier formatting changes, or unrelated edits. Edits must be **caused** by the revert (target additions to remove) or by a typecheck/unit/e2e failure that the revert created.
- ❌ Dispatch agents or `SendMessage` — you are solo here.
- ❌ Stop without either `DONE:` or `FAILED:` — the orchestrator's STATE RB-MERGE relies on those literal prefixes.

View File

@@ -0,0 +1,300 @@
---
name: setup-interview
description: Domain-by-domain interview to produce $CLAUDE_PROJECT_DIR/docs/project-context.json. Invoked once by the orchestrator; the orchestrator then conducts all turns directly using Read/Write/Edit — no agent dispatching.
---
## Overview
A domain-by-domain interview that produces `$CLAUDE_PROJECT_DIR/docs/project-context.json`. You drive it one user turn at a time, persisting after each domain, until the user confirms — exit criterion: a `validated: true` JSON committed and the `VALIDATED` token emitted.
**Scope constraint**: during SETUP-INTERVIEW, your Write / Edit tools are
restricted to `$CLAUDE_PROJECT_DIR/docs/project-context.json` only. Never touch `$CLAUDE_PROJECT_DIR/src/` or
any other path.
## When to Use
- Invoked once by the orchestrator at the start of project setup; the orchestrator then conducts every turn directly.
- Resuming an interrupted interview (`validated: false`) or updating an existing config (`validated: true`).
---
## FIRST ACTION — startup detection
`Read("$CLAUDE_PROJECT_DIR/docs/project-context.json")`:
| State | Action |
|---|---|
| File missing | Start a FRESH interview from domain 1. |
| File exists, `validated: true` | Summarize existing config (template below) and ask: (a) update specific domains, or (b) restart from scratch. |
| File exists, `validated: false` | Resume from the last `pending` entry in `interview_progress`. |
### Existing-config summary template (validated == true)
Output a plain-text message to the user (translate into the user's language at runtime):
```
Here's what I already know about your project:
- Industry: <industry>
- Team size: <team_size>
- Client type: <client_type>
- Main objective: <objective>
- <N> entities defined: <comma-separated names>
- Pipeline: <pipeline_stages joined>
- <N> user roles
Would you like to:
(a) update part of this config (I'll go domain by domain),
(b) start from scratch (I'll wipe everything and redo the full interview)?
```
- Update intent → **UPDATE flow**: for each domain, ask "Current value: `<…>`. Keep as-is, or change it?"
- Restart intent → delete the file, start FRESH.
---
## Per-turn loop (FRESH and UPDATE flows)
Each user turn during SETUP-INTERVIEW:
1. `Read("$CLAUDE_PROJECT_DIR/docs/project-context.json")` — load current state.
2. Apply the user's latest answer to the relevant domain section.
3. Update `interview_progress.domain_N` to `"done"`, keep `validated: false`.
4. `Write("$CLAUDE_PROJECT_DIR/docs/project-context.json", <full updated JSON>)`.
5. Move to the next pending domain.
6. Output **exactly one** of:
- The next question as a **plain text message** to the user (no prefix, no wrapper).
- `VALIDATED` — when final confirmation is received (see Final validation).
- `FAILED: <reason>` — unrecoverable error.
Summarize what you understood after each domain and wait for confirmation before
moving to the next. Never ask multiple domains in one turn.
---
## Domain questions
### Domain 1 — Business context
- Industry, team size, client type (B2B / B2C / mixed), main objective
(prospecting, follow-up, support, other).
### Domain 2 — Entities
- What objects are managed? Relationships between them?
- ⚠️ If an entity resembles `contact`, `company`, `deal`, `tag`, `task`, or
`note` (already in Atomic CRM) → propose extending it (`"type": "extend"`)
rather than recreating it.
### Domain 3 — Custom fields
- Specific fields per entity beyond standard fields.
- Type of each field (text / number / date / boolean / list / file).
- Required vs optional.
### Domain 4 — Pipeline
- Sales or follow-up cycle stages, transition conditions, final stages
(won / lost / archived).
### Domain 5 — User roles
- Who uses the CRM (sales / manager / admin / support).
- Rights per role: read-only / write / delete / admin.
- Multi-tenant needed (data isolated per team)?
### Domain 6 — Integrations
- Email (read / send / tracking)? Slack or other messaging?
- CSV import/export? Inbound or outbound webhooks? External API?
### Domain 7 — UI/UX
- Interface language, theme (light / dark / auto), desired dashboards
(KPIs / charts / lists), information density preferences.
### Domain 8 — Deployment *(skip entirely when MODE=demo)*
- GitHub username, Supabase project name, deployment platform
(Vercel recommended, or GitHub Pages), custom domain.
---
## Cleanup derivation (run after all domains, before consistency checks)
Atomic CRM ships with default entities and features the user may not need.
Before final validation, derive what should be removed to keep the CRM clean.
**Default Atomic CRM elements** (candidates for removal):
| Element | Remove when… |
|---|---|
| `company` entity | User manages individuals only (no B2B account layer) |
| `deal` entity | No sales pipeline — user tracks contacts/support only |
| `task` entity | User did not mention task management |
| `tag` entity | User did not mention categorisation / labelling |
| Pipeline / kanban view | `deal` entity is removed, or no stage-based flow requested |
| Analytics dashboard | User did not mention KPIs, charts, or reporting |
| CSV import/export | User did not mention data migration or bulk operations |
| Multi-tenant / teams | Domain 5 answered "no" to data isolation per team |
**Steps:**
1. Compare domain answers against the table above. Derive a candidate removal list.
2. Present it to the user as a **plain text message**, translated to their language.
**Language rule (strict):** describe only what the user *sees* in the
interface — sections, screens, buttons. Never mention entities, tables,
components, types, or any technical term. The user interprets the
business meaning; you handle the technical implications internally.
Example (translate at runtime):
```
Based on what you told me, it looks like you won't need these sections
in your CRM — I can remove them to keep things clean:
- <plain-language feature name>: <one-sentence business reason>
- …
Does that sound right? Anything you'd like to keep?
```
Plain-language names to use (never the internal names):
| Internal | Say to user |
|---|---|
| `company` entity | the "Companies" section |
| `deal` entity | the "Deals" or "Sales pipeline" section |
| `task` entity | the "Tasks" section |
| `tag` entity | the label / tag feature |
| Pipeline / kanban | the pipeline / kanban board view |
| Analytics dashboard | the analytics and reporting section |
| CSV import/export | the data import and export feature |
| Multi-tenant | team-based data isolation |
3. On user confirmation (or "all good"): populate `cleanup` in the JSON and Write.
4. On user adjustment ("keep X"): remove X from the list, update JSON.
If no element qualifies for removal: skip this step silently (no question asked).
Store the result in the JSON under `"cleanup"` before writing:
```json
"cleanup": {
"entities_to_remove": ["company", "tag"],
"features_to_disable": ["pipeline", "analytics_dashboard", "csv_import_export"]
}
```
## Consistency checks (run before final validation)
- No duplicate field names within the same entity.
- Every entity referenced in `pipeline_stages` exists in `entities`.
- Every role in `user_roles` has at least one permission defined.
- Existing Atomic CRM entities marked `"type": "extend"`, not `"type": "create"`.
- No service-role key or secret in client-side variables.
If a check fails: output one targeted `INTERVIEW:` question to fix it before
proceeding to validation.
---
## Final validation
When all 8 applicable domains are `"done"`:
1. Read the JSON. Produce a compact plain-language summary in the user's
language.
2. Output a plain-text message asking for confirmation (translate at runtime):
> *"<summary>. All good? I'll lock the project spec. (yes / no)"*
3. On any affirmative (yes / ok / valid / go / looks good, in any language):
a. Set `validated: true`. `Write` the file.
b. `Bash("cd \"$CLAUDE_PROJECT_DIR\" && git add docs/project-context.json && git commit -m \"chore(setup): <fresh|update> project context\"")`
c. Output `VALIDATED` — the orchestrator moves to SETUP-PLAN. No additional
text after this token.
4. On negative: ask which domain to revisit; re-enter that domain's questions.
---
## Output format (strict)
Every orchestrator turn during SETUP-INTERVIEW ends with **exactly one** of:
| Output | Meaning |
|---|---|
| Plain text question | Ask the user the next domain question directly; re-enter this skill on the next user turn. No wrapper, no prefix. |
| `VALIDATED` | JSON committed. Move to STATE SETUP-PLAN. |
| `FAILED: <reason>` | Unrecoverable error; surface to user, abort setup. |
---
## JSON schema
```json
{
"validated": false,
"bootstrapped": false,
"project_name": "...",
"github_username": "...",
"supabase_project_name": "...",
"deploy_platform": "vercel|github-pages",
"mode": "demo|full",
"business_context": {
"industry": "...",
"team_size": 0,
"client_type": "B2B|B2C|mixed",
"objective": "..."
},
"entities": [
{
"name": "ticket",
"type": "create|extend",
"base_entity": null,
"fields": [
{ "name": "subject", "type": "text", "required": true }
]
}
],
"pipeline_stages": ["open", "in_progress", "resolved"],
"user_roles": [
{ "name": "admin", "permissions": ["read", "write", "delete"] }
],
"integrations": [],
"ui": { "language": "en", "theme": "light|dark|auto" },
"cleanup": {
"entities_to_remove": [],
"features_to_disable": []
},
"interview_progress": {
"domain_1": "done",
"domain_2": "pending",
"domain_3": "pending",
"domain_4": "pending",
"domain_5": "pending",
"domain_6": "pending",
"domain_7": "pending",
"domain_8": "pending"
},
"phase_status": {
"spec": { "status": "pending" },
"fork": { "status": "pending" },
"supabase": { "status": "pending" },
"env": { "status": "pending" },
"deploy": { "status": "pending" }
},
"tickets": []
}
```
The `tickets` array is populated later by `planner` in SETUP_MODE — leave it
empty here.
---
## Red Flags
- Asking more than one domain in a single turn, or skipping the per-domain confirmation.
- Writing or editing any path other than `docs/project-context.json`.
- Setting `validated: true` before the consistency checks pass.
- Mentioning entities, tables, components, or other technical terms to the user (use plain-language feature names).
- An entity resembling a built-in (`contact`/`company`/`deal`/`tag`/`task`/`note`) marked `"type": "create"` instead of `"extend"`.
- A service-role key or secret stored in a client-side variable.
- Emitting anything other than exactly one output contract (plain-text question / `VALIDATED` / `FAILED:`) per turn.
## Verification
Before emitting `VALIDATED`:
- [ ] All 8 applicable domains are `"done"` (domain 8 skipped when `MODE=demo`).
- [ ] All consistency checks pass (no duplicate fields, pipeline entities exist, every role has a permission, built-ins use `"extend"`, no client-side secrets).
- [ ] The `cleanup` derivation was presented in plain language and confirmed (or skipped silently when nothing qualified).
- [ ] The user gave an affirmative final confirmation.
- [ ] `validated: true` written, JSON committed, and `VALIDATED` is the only output (no trailing text).

View File

@@ -0,0 +1,198 @@
---
name: shadcn-customization
description: Shadcn/ui theming and component customization — CSS variables, OKLCH colors, dark mode, variants, wrappers. Load for any ticket involving colors, theme, UI layout, or component styling.
---
# Customization & Theming
Reference for theming and component customization. Components reference semantic CSS variable tokens change the variables to change every component. Read the relevant section, then check the Red Flags and Verification list.
## When to Use
- Changing colors, theme, dark mode, or border radius.
- Adding a custom color token or a new component variant.
- Customizing or wrapping a shadcn/ui component.
For component *architecture* and where to import from, see `Skill({skill: "frontend-dev"})`.
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
|---|---|
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. Atomic CRM wires this
through its own `ThemeProvider` at `src/components/admin/theme-provider.tsx`,
which is already mounted from `src/components/admin/admin.tsx`. To customize
the palette, pass `lightTheme` / `darkTheme` props to the `<CRM>` component
(see `src/App.tsx`).
---
## Changing the Theme
```bash
# Apply a preset from ui.shadcn.com.
npx shadcn@latest apply --preset a2r6bw
# Positional shorthand also works.
npx shadcn@latest apply a2r6bw
# Switch to a named preset, overwrite existing components.
npx shadcn@latest apply --preset nova
# Preserve existing components instead.
npx shadcn@latest init --preset nova --force --no-reinstall
# Use a custom theme URL.
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">Click</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="mx-auto max-w-md">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # diff for a specific file
```
---
## Red Flags
- A hardcoded hex/rgb color in a component instead of a semantic token (`bg-primary`, `text-muted-foreground`).
- A new CSS file created for custom colors instead of adding them to the global CSS file.
- A custom color used without registering it under `@theme inline` for Tailwind v4.
- Editing component source for a one-off when a `variant` or `className` would do.
- Palette changes wired anywhere but the `lightTheme`/`darkTheme` props on `<CRM>`.
- A token defined in `:root` but not in `.dark` (or vice versa) — one mode is left broken.
## Verification
- [ ] Colors use semantic tokens, never hardcoded values.
- [ ] Custom colors live in the global CSS file and are registered under `@theme inline`.
- [ ] Every new token is defined in both `:root` and `.dark`.
- [ ] Customization uses the lowest-effort approach that works (variant → `className` → new variant → wrapper).
- [ ] Palette changes go through `lightTheme`/`darkTheme` on `<CRM>`.

View File

@@ -0,0 +1,81 @@
---
name: update-branding
description: Rebrand Atomic CRM — change the application logo (the wordmark in the header and on the login/signup pages) and/or the title/name. Use when the user wants to change, swap, update, or rebrand the CRM logo or title. Handles the two light/dark-mode logo variants, the three places the title is hardcoded, the config that points at them, and — optionally — the browser favicon and PWA app icons.
---
# update-branding
## Overview
Changes the CRM **logo** and/or **title**. The logo shows in **two themes** across **five screens** (desktop + mobile header, login, signup, confirmation) and the title is hardcoded in **three** places a naive single-file edit leaves one stale. The steps below catch all of them.
Exit criterion: the logo renders correctly in both themes across all five screens, and the title is consistent in the header, browser tab, and PWA name.
## When to Use
- The user wants to change, swap, or rebrand the CRM logo and/or title/name.
- Optionally, when they also ask to update the favicon or PWA app icons.
Not for theme colors or component styling see `Skill({skill: "shadcn-customization"})`.
## Logo: two asset families
Confirm which the user means — most requests mean only #1.
1. **App logo / wordmark** (common) — `src/components/atomic-crm/root/logos/`, imported as module assets by `defaultConfiguration.ts` (Vite resolves the URL relative to the JS chunk, so it survives nested routes like `/oauth/consent` and sub-path deploys). `logo_atomic_crm_dark.svg` is **light-colored** art shown in **dark mode** + all auth pages; `logo_atomic_crm_light.svg` is **dark-colored** art shown in **light mode**. (A standalone unused `logo_atomic_crm.svg` still lives in `public/logos/`.)
> Files are named by **the mode they display in**, not their color — the "dark-mode" logo must read on a dark background.
2. **Favicon + PWA icons** — only if asked. Much bigger (34 PNGs); see [below](#favicon--app-icons-optional).
## Code defaults vs. runtime Settings — they conflict
- **Code defaults (this skill):** edit `defaultConfiguration.ts` + assets. Committed, applies everywhere, and the only way to change the browser-tab title / favicon / PWA icons.
- **Settings UI** (`/settings` → Branding, [`SettingsPage.tsx`](../../../src/components/atomic-crm/settings/SettingsPage.tsx)): a logged-in user edits title + uploads logos, **per-browser**, saved to `localStorage` as `CRM.app.configuration` (logos as base64), never committed. Covers only the in-app logo + header title.
> ⚠️ **The store shadows the code.** `useConfigurationContext` returns `{ ...defaultConfiguration, ...storedConfig }` ([`ConfigurationContext.tsx`](../../../src/components/atomic-crm/root/ConfigurationContext.tsx)). If branding was ever saved via `/settings`, your edits to `defaultConfiguration.ts` won't show in that browser — clear the `CRM.app.configuration` localStorage key. Suspect this whenever a code change has no visual effect.
## Steps
1. **Gather the asset(s).** Ask for the file/URL, format (SVG vs PNG), and whether there are **separate light/dark variants** or one image.
- One variant → set both config keys to it; **warn** about poor contrast in one theme (auth pages always use the dark-mode key).
- Single-fill SVG → derive the other variant by swapping the fill (`white` ↔ `black`).
2. **Place the files** in `src/components/atomic-crm/root/logos/`:
- **Drop-in (simplest):** overwrite `logo_atomic_crm_dark.svg` / `logo_atomic_crm_light.svg`, keeping the exact filenames + `.svg`. The imports in `defaultConfiguration.ts` already point here — done. (SVG only.)
- **New name / format (e.g. PNG):** save as new files next to them, then update the two `import` statements at the top of `defaultConfiguration.ts` (they feed `defaultDarkModeLogo` / `defaultLightModeLogo`; module-relative paths, so `"./logos/<file>"`). Do **not** revert them to bare public-path strings — that reintroduces issue #291.
3. **Update the title** (if the name changes) in all **three** places — `defaultTitle` drives only the first:
- `defaultConfiguration.ts` → `defaultTitle` — header `<h1>` + logo `alt`.
- `index.html` → `<title>` — browser-tab title (hardcoded, not set at runtime).
- `public/manifest.json` → `short_name` + `name` — installed-PWA name.
Consumers (no edits if you keep the config keys): `layout/Header.tsx` + `dashboard/MobileDashboard.tsx` render both variants (CSS-toggled, `h-6`); `login/{LoginPage,SignupPage,ConfirmationRequired}.tsx` render **only** `darkModeLogo`.
## Rationalizations
| Rationalization | Reality |
|---|---|
| "I changed the logo file, that's the whole job." | The logo shows in two themes across five screens and the title lives in three files. One edit leaves a stale variant. |
| "My code change has no visual effect, so it didn't apply." | The `/settings` store shadows `defaultConfiguration.ts` per browser. Clear the `CRM.app.configuration` localStorage key. |
| "One image is fine for both themes." | The auth pages always use the dark-mode key; a single asset reads wrong on one background. Warn or derive the second variant. |
| "I'll regenerate a couple of favicon sizes." | The PWA set is 34 PNGs. Regenerate every size from one source or ask for the set don't hand-edit a few. |
## Red Flags
- Editing only one of the two logo asset keys, or swapping the light/dark files.
- Changing the title in `defaultConfiguration.ts` but not `index.html` and `manifest.json`.
- A code change with no visible effect, with the `CRM.app.configuration` store left uncleared.
- Hand-editing a subset of favicon/PWA sizes instead of regenerating the full set.
- Touching the docs-site logo (`doc/`) when it wasn't requested.
## Verification
`make typecheck` catches only config-key typos. Then `make start` and check: header in **both themes**, **desktop + mobile**; the **login/signup** pages (dark bg); and if the title changed, the header `<h1>`, browser tab, and PWA name. A logo wrong in exactly one theme means both keys point at one asset, or the light/dark files are swapped.
- [ ] Both logo variants point at the correct light/dark assets (named by display mode, not color).
- [ ] Logo renders in both themes, desktop + mobile, and on login/signup/confirmation.
- [ ] If the name changed, the title is updated in `defaultConfiguration.ts`, `index.html`, and `manifest.json`.
- [ ] If no visual effect appears, the `CRM.app.configuration` localStorage key was cleared.
- [ ] Favicon/PWA icons regenerated as a full set only if explicitly requested.
## Favicon & app icons (optional)
Only if asked. Referenced by `index.html` + `public/manifest.json`: `public/favicon.ico` and `public/appIcon/*.png` (34 sizes incl. `maskable_icon*`). Regenerate **every** size from one square source (`sharp` / `pwa-asset-generator`) or ask the user for the set — don't hand-edit a few. Confirm scope first; `theme_color`/`background_color` in `manifest.json` may also need updating.
> The docs-site logo (`doc/astro.config.mjs` → `doc/public/logo_atomic_crm_*.svg`) is a separate Astro site — leave it unless asked.

View File

@@ -0,0 +1,54 @@
---
name: worktree-detection
description: How to find a Git worktree by task number without hardcoding paths. Use in hooks or scripts that need to locate a specific task's worktree.
---
# Worktree Detection
Reference snippet for locating a task's Git worktree from its task number,
without hardcoding paths.
## When to Use
- Inside a hook or script that must act on a specific task's worktree.
- Any time you'd otherwise hardcode a `/tmp/...` worktree path.
## Pattern
```bash
TASK_NUM="TASK-005"
WORKTREE=$(git worktree list --porcelain \
| grep "^worktree " \
| awk '{print $2}' \
| grep -i "${TASK_NUM}" \
| head -1)
if [ -z "$WORKTREE" ] || [ ! -d "$WORKTREE" ]; then
echo "No worktree found for ${TASK_NUM} — skipping"
exit 0
fi
```
## Why this works
`git worktree list` reads from Git's internal registry — it knows every worktree regardless of where it was created. No assumptions about directory structure or machine-specific paths.
## Extracting TASK_NUM from agent variables
```bash
TASK_NUM=$(echo "$TASK_OWNER $TASK_SUBJECT" | grep -oP 'TASK-\d+' | head -1)
```
Works whether the task number appears in the owner field or the subject line.
## Red Flags
- A hardcoded `/tmp/...` worktree path instead of querying `git worktree list`.
- Assuming a directory layout rather than reading Git's registry.
- Treating a missing worktree as an error instead of skipping cleanly (`exit 0`).
## Verification
- [ ] Worktree resolved via `git worktree list --porcelain`, never a hardcoded path.
- [ ] A missing/absent worktree is handled (skip, don't crash).

View File

@@ -0,0 +1,205 @@
---
name: writing-migrations
description: Generate Supabase SQL migrations at deploy time from the session branch diff. Load this when your developer dispatch asks you to generate the deploy-time migration (it points you at the shared <base>/simple worktree). This is NOT a feature ticket — the workflow below replaces the normal ticket rules: here you DO write SQL under supabase/migrations/, and you do not edit TS/TSX/CSS.
---
# Writing Migrations (deploy-time round)
## Overview
A workflow that turns a session's *code* changes into the *minimal* SQL that
makes the real Supabase schema match what the session's app now expects —
nothing more.
You (the `developer`) have been asked to generate the deploy-time migration. The
"never write migrations / no schema" rule of normal ticket work does NOT apply
here: writing SQL under `supabase/migrations/` is the whole job. Do not edit any
TS/TSX/CSS — the schema diff already comes from the session branch; you only
translate it to SQL.
Exit criterion: either a committed, idempotent migration whose delta is provably the net schema change, or an explicit `NO_MIGRATION_NEEDED` when there is no schema impact.
Your worktree is `<WORKTREE_BASE>/simple` on `<SESSION_SHORT_ID>/simple`, forked from `session/<SESSION_SHORT_ID>` (`<WORKTREE_BASE>` =
`/tmp/<$CLAUDE_PROJECT_DIR with every "/" replaced by "_">/<SESSION_ID>`). Every Bash call must `cd <WORKTREE_PATH> && …` (stateless shells).
## When to Use
- The deploy-time migration round, after a session's app changes have merged to `session/<SESSION_SHORT_ID>` and need their schema counterpart.
- Triggered only by the orchestrator dispatching a `developer` that loads this skill, never invoked ad hoc by a feature developer (per-ticket developers never write SQL).
Skip (write nothing, report `NO_MIGRATION_NEEDED`) when the net diff has no
schema impact: CSS, layout, copy, or test-only changes.
## Process
Each step ends in a checkpoint, do not advance until its evidence holds.
### 1. Compute the session's net change
```bash
cd <WORKTREE_PATH>
git diff session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID>
```
This is the branch's full diff since creation. Do NOT use `git merge-base`
(it collapses after the first promotion). Do NOT diff against the base branch
(other sessions pollute it).
**Checkpoint:** you have the full session diff, produced from the two-dot range above (not merge-base, not the base branch).
### 2. Identify schema-relevant changes
From that diff, keep only changes that imply a database schema change:
- Entity TypeScript types (e.g. `src/**/types.ts`, resource type defs).
- Fake-data generators that add/remove fields.
- dataProvider resource registrations (new resource = new table).
Ignore CSS, component layout, copy, tests.
**Checkpoint:** you have an explicit list of schema-relevant changes (possibly
empty). If empty, jump to step 3's no-op path.
### 3. Regenerate ONE net migration against what is ALREADY DEPLOYED (session-base)
"Deployed" = the migrations present at `session-base/<SESSION_SHORT_ID>` (the fork
anchor, i.e. the last deployed state). A migration YOU generated in an EARLIER
iteration of this same session is NOT deployed: it sits on the session branch but was
never pushed. Treating it as deployed is exactly what makes iterations STACK (add
column `foo`, then a second migration to rename it to `bar`, ...) instead of one clean
migration. The user does not want to push N migrations per change.
So, before authoring any SQL:
1. Find this session's own prior migration(s):
`git diff --diff-filter=A session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID> -- supabase/migrations/`
(files ADDED since the fork = generated by an earlier iteration of this session).
2. **Delete them.** You will regenerate ONE fresh file that is the whole net change.
3. Author SQL for the NET delta between the desired schema (from the TS types) and the
schema as of `session-base` ONLY. Never layer an incremental delta on top of your
own prior migration.
Result: however many times the user iterates before deploy, the session carries
exactly ONE migration = `net(session-base -> final schema)`. If the net diff has no
schema impact, write **nothing** and report `NO_MIGRATION_NEEDED`.
**Hard boundary:** overwrite-and-regenerate is safe ONLY because the migration is not
yet deployed. Once it is pushed / promoted it is immutable; a later change is a NEW
forward migration, never an edit of the pushed one.
**Surface note.** Technical-harness writes to `supabase/migrations/`. On the web-chat
surface (CRM Builder) the launcher stages migrations in `supabase/migrations-pending/`
and promotes them to `supabase/migrations/` after the user confirms: follow the
launcher's staging, do not invent your own pending/promoted handling. The
regenerate-one-net-migration rule above applies either way (it is about NOT stacking,
independent of which directory the launcher stages into).
**Checkpoint:** at most ONE session-owned migration file exists, and its SQL is the
full net delta from `session-base` (not an increment on a prior session migration).
### 4. Write idempotent SQL
Write to `supabase/migrations/<YYYYMMDDHHMMSS>_<SESSION_SHORT_ID>_migration_<slug>.sql`
(timestamp via `date -u +%Y%m%d%H%M%S`). Use `IF NOT EXISTS` / `IF EXISTS`,
correct column types matching the TS types, FKs, and RLS for new tables (RLS
enabled + policies, never `USING (true)`).
**Checkpoint:** re-running the migration on an already-migrated database is a
no-op (every statement is guarded), and every new table has RLS enabled with at least one non-`USING (true)` policy.
### 5. View-recreation rule (BLOCKING correctness)
When a migration adds a column on a table referenced by a view in
`supabase/schemas/03_views.sql`, recreate the view with **`CREATE OR REPLACE
VIEW`** and place the new column as the **very last item** in the SELECT
list — after every existing column AND after every existing computed `AS`
alias (e.g. `count(...) as nb_deals`).
PostgreSQL forbids any ordinal shift in an existing view's SELECT list
(error 42P16). "Between the raw columns and the computed aggregates" is
**still a shift** — the aggregate's position increases by one. There are no
exceptions. The mechanical rule is: positions 1..N of the new view must be
identical to positions 1..N of the old view; the new column is position N+1.
Why not `DROP VIEW … CASCADE; CREATE VIEW …`? It silently drops dependent
views/materialized views/rules, loses any explicit `REVOKE`s on the view
(re-granted by default privileges at the next deploy without warning), and
reads as a destructive operation in audit. Reserve it for cases `CREATE OR
REPLACE` genuinely can't handle (column **removal** or **rename**). If you
use it, enumerate dropped dependents in a SQL comment and re-create them in
the same migration.
PostgREST queries the view, not the table — a missing update makes the
column invisible to the app. The view's column order doesn't matter to API
consumers (they address by name), so "append at end" has no API cost.
Example — adding `importance` to `companies_summary` (the view ends with
`count(...) as nb_deals, count(...) as nb_contacts`):
```sql
create or replace view public.companies_summary with (security_invoker = on) as
select
c.id, c.created_at, c.name, …, c.logo,
count(distinct d.id) as nb_deals,
count(distinct co.id) as nb_contacts,
c.importance -- LAST, after every existing item
from public.companies c
left join public.deals d on c.id = d.company_id
left join public.contacts co on c.id = co.company_id
group by c.id;
```
Then update `supabase/schemas/03_views.sql` to the **same order** — the
declarative schema must mirror the deployed view, including the chronological
"append at end" placement of newer columns. Don't reorder the schema file to
look prettier — schema drift breaks future `supabase db diff` generations.
**Checkpoint:** for every table that gained a column AND feeds a view,
positions 1..N of the recreated view match the old view exactly, the new column is position N+1, and `03_views.sql` mirrors that order.
### 6. Commit and hand off
Commit the SQL on `<SESSION_SHORT_ID>/simple`:
```bash
cd <WORKTREE_PATH> && git add supabase/migrations && git commit -m "migration(<SESSION_SHORT_ID>): <slug>"
```
Then stop and emit the output contract as your very last line — one of:
- `DONE: branch=<SESSION_SHORT_ID>/simple migration=<filename> summary=<what the SQL does>`
- `NO_MIGRATION_NEEDED` — only after computing the diff (step 1) and confirming no schema impact.
- `FAILED: <one-line reason>`
SubagentStop hooks (typecheck/prettier/unit/e2e) run automatically; they should
pass since you only touched SQL. The orchestrator then sends you to
quality-reviewer (`MODE: migration-review`) and the merger.
For Postgres correctness you may load `Skill({skill: "supabase-postgres-best-practices"})`.
**Checkpoint:** the migration is committed on `<SESSION_SHORT_ID>/simple` and the SubagentStop validation chain is green.
## Rationalizations
| Rationalization | Reality |
|---|---|
| "I'll diff against the base branch, it's close enough." | Other sessions pollute the base branch. Only the two-dot `session-base..session` range is the true net change. |
| "This column is probably already deployed, I'll re-emit it to be safe." | Re-emitting a deployed change is drift, not safety. Guard with `IF NOT EXISTS` and emit only the delta. |
| "The view still selects all the right columns, order doesn't matter." | Postgres rejects any ordinal shift (42P16). Order is a hard correctness constraint, not cosmetics. |
| "DROP VIEW CASCADE then CREATE is simpler." | It silently drops dependents and loses REVOKEs. Use `CREATE OR REPLACE` unless you are removing/renaming a column. |
| "No schema change, but I'll write an empty migration anyway." | A no-op deploy is valid. Write nothing and report `NO_MIGRATION_NEEDED`. |
## Red Flags
- Diff computed via `git merge-base` or against the base branch.
- A migration statement without an `IF [NOT] EXISTS` guard.
- A new table with no RLS, or an RLS policy using `USING (true)`.
- A view recreated by `DROP … CASCADE` for a mere column *addition*.
- A new column inserted anywhere but the last position of an existing view.
- `03_views.sql` and the migration's view definition disagreeing on column order.
- Re-emitting SQL already present in `supabase/migrations/`.
## Verification
- [ ] Diff taken from `session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID>`.
- [ ] Every emitted statement is an incremental delta not already in `supabase/migrations/`.
- [ ] All statements are idempotent (`IF [NOT] EXISTS`).
- [ ] New tables have RLS enabled with real policies (never `USING (true)`).
- [ ] Affected views recreated via `CREATE OR REPLACE`, new column last, matching `03_views.sql`.
- [ ] SQL committed on `<SESSION_SHORT_ID>/simple`; SubagentStop validation green — OR `NO_MIGRATION_NEEDED` reported with no file written.