diff --git a/.claude/settings.json b/.claude/settings.json
index ce23189..fceb05d 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -3,5 +3,8 @@
"allow": [
"Bash(.venv/Scripts/pytest tests/test_auth.py -v)"
]
+ },
+ "enabledPlugins": {
+ "ralph-loop@claude-plugins-official": true
}
}
diff --git a/.gitmodules b/.gitmodules
index d5c76e7..09f63a1 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -7,3 +7,6 @@
[submodule "website"]
path = website
url = https://git.muticolturano.com/adiuvAI/website.git
+[submodule "waitlist"]
+ path = waitlist
+ url = https://git.muticolturano.com/adiuvAI/waitlist.git
diff --git a/adiuvAI b/adiuvAI
index dd98aaa..0371a46 160000
--- a/adiuvAI
+++ b/adiuvAI
@@ -1 +1 @@
-Subproject commit dd98aaaf4d9ca2a8611b95523fe50e78420e1fe2
+Subproject commit 0371a46731e771c8358231a531870d7d812d5f52
diff --git a/api b/api
index a85f8fd..7ccdad4 160000
--- a/api
+++ b/api
@@ -1 +1 @@
-Subproject commit a85f8fde2900f6f6d90412980def9ac019bccb88
+Subproject commit 7ccdad431f2f8c00f55ab27702969b13f34af617
diff --git a/docs/PROMPT-onboarding.md b/docs/PROMPT-onboarding.md
new file mode 100644
index 0000000..95a98d7
--- /dev/null
+++ b/docs/PROMPT-onboarding.md
@@ -0,0 +1,713 @@
+# RALPH LOOP PROMPT — First-Run Onboarding Wizard
+
+> **How to run:**
+> ```
+> /ralph-loop "Implement the onboarding wizard exactly as specified in docs/PROMPT-onboarding.md. Output ONBOARDING COMPLETE when all phases pass lint." --max-iterations 25 --completion-promise "ONBOARDING COMPLETE"
+> ```
+
+---
+
+## INSTRUCTIONS FOR CLAUDE
+
+You are implementing a first-run onboarding wizard for the adiuvAI Electron app. This is a **multi-file, multi-iteration** task. On each iteration:
+
+1. **Read this file** in full.
+2. **Inspect which tasks are already done** by checking if the target files exist and contain the expected code.
+3. **Pick the next incomplete task** (always in phase order: Phase 1 → 2 → 3 → 4).
+4. **Implement it**, then **run the relevant lint command** before exiting.
+5. When ALL phases are complete AND both lint commands pass, output `ONBOARDING COMPLETE`.
+
+**DO NOT** skip phases. **DO NOT** implement out of order — backend must exist before the FE can call it.
+
+**LINT COMMANDS** (run after each phase):
+- Backend: `cd api && ruff check . --fix`
+- Frontend: `cd adiuvAI && npx eslint . --fix`
+
+---
+
+## WHAT THIS FEATURE DOES
+
+After login, new users see a chat-styled wizard that collects 5 fields:
+- `job_role`, `industry`, `primary_use_case`, `tone_preference`, `language`
+
+These are stored encrypted in `MemoryCore` (backend) so the AI agents personalize responses. Three formatting prefs (`timezone`, `date_format`, `time_format`) are auto-detected from the OS and stored in electron-store (FE only) — the LLM never sees them. The FE formats all timestamp columns in tool-result rows before sending them back to the backend.
+
+**Storage split:**
+| Field | Where | Why |
+|-------|-------|-----|
+| job_role, industry, primary_use_case, tone_preference, language | `MemoryCore` (backend, encrypted) | LLM needs these for personalization |
+| timezone, date_format, time_format | electron-store (FE) | FE formatter only — LLM must never see raw timestamps |
+
+**Key architectural fact:** `memory_middleware.py` `enrich_context()` already injects `core_memory` into every orchestrator call. Writing to `MemoryCore` is sufficient — no system-prompt changes needed.
+
+---
+
+## PHASE 1 — Backend (api/)
+
+### TASK 1.1: Alembic migration — `onboarding_completed_at` column
+
+**File:** `api/alembic/versions/XXX_add_onboarding_completed_at.py` (new)
+
+Create a new Alembic migration that adds:
+```sql
+ALTER TABLE users ADD COLUMN onboarding_completed_at TIMESTAMPTZ NULL;
+```
+
+Use the existing migrations in `api/alembic/versions/` as a pattern reference. The revision ID should be sequential (check the latest existing migration number and increment).
+
+**Done signal:** File exists in `api/alembic/versions/` with the column add.
+
+---
+
+### TASK 1.2: Add column to User model
+
+**File:** `api/app/models.py`
+
+Find the `User` class (around line 63-94). Add:
+```python
+onboarding_completed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, default=None
+)
+```
+
+Import `DateTime` from sqlalchemy if not already imported.
+
+**Done signal:** `User` model has `onboarding_completed_at` field.
+
+---
+
+### TASK 1.3: Extend UserProfile schema
+
+**File:** `api/app/schemas.py`
+
+Find `UserProfile` (around line 27-33). Add two fields:
+```python
+onboarding_completed_at: int | None = None # epoch ms, null = not onboarded
+memory: dict[str, str] = Field(default_factory=dict) # decrypted core memory k/v
+```
+
+**Done signal:** `UserProfile` has both new fields.
+
+---
+
+### TASK 1.4: Extend `get_current_user` to return memory + onboarding flag
+
+**File:** `api/app/api/middleware/auth.py`
+
+In `get_current_user()`, after fetching the user row and resolving the tier:
+1. Read `user.onboarding_completed_at` — convert to epoch ms (int) or None.
+2. Use `MemoryMiddleware(db).enrich_context(user.id)` to load decrypted core memory. Extract the `core` dict → `{label: value}` pairs.
+3. Return `UserProfile(..., onboarding_completed_at=..., memory=...)`.
+
+This requires `get_current_user` to also receive the `db: AsyncSession` dependency. Check if it already does — if not, add `Depends(get_session)`.
+
+**Done signal:** `GET /api/v1/auth/me` returns `onboarding_completed_at` and `memory` fields.
+
+---
+
+### TASK 1.5: New route — `PUT /auth/me/memory`
+
+**File:** `api/app/api/routes/auth.py`
+
+Add a new route (do NOT modify `_UpdateProfileRequest`):
+
+```python
+class _UpdateMemoryRequest(BaseModel):
+ memory: dict[str, str] = Field(default_factory=dict)
+ mark_onboarded: bool = False
+
+@router.put("/me/memory", response_model=UserProfile)
+async def update_memory(
+ body: _UpdateMemoryRequest,
+ current_user: UserProfile = Depends(get_current_user),
+ db: AsyncSession = Depends(get_session),
+) -> UserProfile:
+ mw = MemoryMiddleware(db)
+ for key, value in body.memory.items():
+ await mw.update_core(current_user.id, key, value)
+ if body.mark_onboarded:
+ result = await db.execute(select(User).where(User.id == current_user.id))
+ user = result.scalar_one()
+ user.onboarding_completed_at = datetime.now(timezone.utc)
+ await db.commit()
+ # Re-fetch profile and return
+ return await get_current_user(...) # use same logic as GET /me
+```
+
+Also add a companion route to reset onboarding (for "Re-run onboarding" in Settings):
+```python
+@router.post("/me/onboarding/reset")
+async def reset_onboarding(
+ current_user: UserProfile = Depends(get_current_user),
+ db: AsyncSession = Depends(get_session),
+):
+ result = await db.execute(select(User).where(User.id == current_user.id))
+ user = result.scalar_one()
+ user.onboarding_completed_at = None
+ await db.commit()
+ return {"status": "reset"}
+```
+
+**Done signal:** Both routes exist and are syntactically correct.
+
+---
+
+### TASK 1.6: New route — `POST /auth/onboarding/normalize`
+
+**File:** `api/app/api/routes/auth.py`
+
+```python
+class _NormalizeRequest(BaseModel):
+ inputs: dict[str, str] # {"job_role": "i build websites"}
+
+class _NormalizeResponse(BaseModel):
+ normalized: dict[str, str]
+
+@router.post("/onboarding/normalize", response_model=_NormalizeResponse)
+async def normalize_onboarding(
+ body: _NormalizeRequest,
+ current_user: UserProfile = Depends(get_current_user),
+) -> _NormalizeResponse:
+ """One-shot LLM normalization for free-text onboarding answers."""
+ if not body.inputs:
+ return _NormalizeResponse(normalized={})
+ try:
+ llm = get_llm("gpt-4o-mini", temperature=0)
+ prompt = (
+ "You normalize user onboarding answers into clean, ≤3-word canonical labels.\n"
+ "Return a JSON object with the same keys and normalized values.\n"
+ "Examples: 'i build websites' → 'Web Developer', 'tech-ish stuff' → 'Technology'\n"
+ f"Input: {json.dumps(body.inputs)}"
+ )
+ response = await llm.ainvoke(
+ [{"role": "system", "content": "You normalize user inputs. Return JSON only."},
+ {"role": "user", "content": prompt}],
+ )
+ normalized = json.loads(response.content)
+ return _NormalizeResponse(normalized=normalized)
+ except Exception:
+ # LLM failure must never block onboarding — return inputs unchanged
+ return _NormalizeResponse(normalized=body.inputs)
+```
+
+Use `get_llm` from `app.core.llm`. Use `json` stdlib. The `try/except` is critical — flaky LLM must never block the wizard.
+
+**Done signal:** Route exists, has the safety try/except, returns inputs on failure.
+
+---
+
+### TASK 1.7: Backend lint check
+
+Run: `cd api && ruff check . --fix`
+
+Fix any issues before proceeding to Phase 2.
+
+**Done signal:** `ruff check .` exits 0.
+
+---
+
+## PHASE 2 — Electron Main Process (adiuvAI/src/main/)
+
+### TASK 2.1: Extend `UserProfileSchema`
+
+**File:** `adiuvAI/src/shared/api-types.ts`
+
+Find `UserProfileSchema` (Zod schema). Add:
+```ts
+onboardingCompletedAt: z.number().int().nullable().optional(),
+memory: z.record(z.string(), z.string()).default({}),
+```
+
+**Done signal:** Schema has both fields.
+
+---
+
+### TASK 2.2: Add formatPrefs to electron-store
+
+**File:** `adiuvAI/src/main/store.ts`
+
+Extend the `AppSettings` interface:
+```ts
+formatPrefs: {
+ timezone: string;
+ dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
+ timeFormat: '12h' | '24h';
+} | null;
+```
+
+Default to `null` in the store defaults.
+
+Add helpers:
+```ts
+export function getFormatPrefs(): FormatPrefs | null {
+ return getStore().get('formatPrefs', null);
+}
+
+export function setFormatPrefs(prefs: FormatPrefs): void {
+ getStore().set('formatPrefs', prefs);
+}
+```
+
+Export `FormatPrefs` as a type.
+
+**Done signal:** `getFormatPrefs()` and `setFormatPrefs()` exported from store.ts.
+
+---
+
+### TASK 2.3: Create locale-defaults helper
+
+**File:** `adiuvAI/src/main/auth/locale-defaults.ts` (new)
+
+```ts
+import { app } from 'electron';
+
+export interface FormatPrefs {
+ timezone: string;
+ dateFormat: string;
+ timeFormat: '12h' | '24h';
+}
+
+export function detectFormatPrefs(): FormatPrefs {
+ const locale = app.getLocale();
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const hour12 = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12;
+ const timeFormat = hour12 ? '12h' : '24h';
+ const dateFormat = inferDateFormat(locale);
+ return { timezone, timeFormat, dateFormat };
+}
+
+export function detectLanguage(): string {
+ return app.getLocale(); // e.g. 'it-IT', 'en-US'
+}
+
+function inferDateFormat(locale: string): string {
+ // MDY locales
+ const mdyLocales = ['en-US', 'en-PH', 'en-BZ'];
+ if (mdyLocales.some(l => locale.startsWith(l))) return 'MM/dd/yyyy';
+ // YMD locales (CJK, ISO-oriented)
+ const ymdPrefixes = ['ja', 'zh', 'ko', 'hu', 'lt', 'sv', 'fi'];
+ if (ymdPrefixes.some(p => locale.startsWith(p))) return 'yyyy-MM-dd';
+ // Default: DMY (most of the world)
+ return 'dd/MM/yyyy';
+}
+```
+
+**Done signal:** File exists with `detectFormatPrefs()` and `detectLanguage()` exported.
+
+---
+
+### TASK 2.4: Create format-row helper
+
+**File:** `adiuvAI/src/main/api/format-row.ts` (new)
+
+```ts
+import type { FormatPrefs } from '../auth/locale-defaults';
+
+const TIMESTAMP_COLUMNS = new Set([
+ 'createdAt', 'updatedAt', 'dueDate', 'date', 'endDate',
+ 'lastRunAt', 'startedAt', 'completedAt',
+]);
+
+export function formatRows>(
+ rows: T[],
+ prefs: FormatPrefs,
+): T[] {
+ return rows.map(row => formatRow(row, prefs));
+}
+
+export function formatRow>(
+ row: T,
+ prefs: FormatPrefs,
+): T {
+ const result = { ...row };
+ for (const col of TIMESTAMP_COLUMNS) {
+ if (col in result && typeof result[col] === 'number') {
+ (result as Record)[col] = formatTimestamp(
+ result[col] as number,
+ prefs,
+ );
+ }
+ }
+ return result;
+}
+
+function formatTimestamp(epochMs: number, prefs: FormatPrefs): string {
+ const date = new Date(epochMs);
+ const hour12 = prefs.timeFormat === '12h';
+
+ const opts: Intl.DateTimeFormatOptions = {
+ timeZone: prefs.timezone,
+ hour12,
+ hour: '2-digit',
+ minute: '2-digit',
+ };
+
+ const timePart = date.toLocaleTimeString('en-US', opts);
+
+ const day = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, day: '2-digit' }).slice(-2);
+ const month = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, month: '2-digit' }).slice(-2);
+ const year = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, year: 'numeric' }).slice(0, 4);
+
+ let datePart: string;
+ switch (prefs.dateFormat) {
+ case 'MM/dd/yyyy': datePart = `${month}/${day}/${year}`; break;
+ case 'yyyy-MM-dd': datePart = `${year}-${month}-${day}`; break;
+ default: datePart = `${day}/${month}/${year}`; break;
+ }
+
+ return `${datePart} ${timePart}`;
+}
+```
+
+**Done signal:** File exists with `formatRow` and `formatRows` exported.
+
+---
+
+### TASK 2.5: Wire format-row into drizzle-executor
+
+**File:** `adiuvAI/src/main/api/drizzle-executor.ts`
+
+Import `formatRows`, `formatRow` from `./format-row` and `getFormatPrefs` from `../store` and `detectFormatPrefs` from `../auth/locale-defaults`.
+
+Find every place that returns `{ rows }` or `{ row }` results. Wrap them:
+- `rows` → `formatRows(rows, getFormatPrefs() ?? detectFormatPrefs())`
+- `row` → `formatRow(row, getFormatPrefs() ?? detectFormatPrefs())`
+
+The `?? detectFormatPrefs()` fallback handles the edge case where executor runs before first auth.status seed.
+
+**Important:** Only format on `handleList`, `handleGet`, `handleInsert`, `handleUpdate` return paths — NOT on delete. Do not mutate the original rows — `formatRow` returns a new object.
+
+**Done signal:** All select/get/insert/update returns pass through formatRow(s).
+
+---
+
+### TASK 2.6: Add auth methods to auth-manager
+
+**File:** `adiuvAI/src/main/auth/auth-manager.ts`
+
+Add two methods to the `AuthManager` class:
+
+```ts
+async updateMemory(
+ memory: Record,
+ markOnboarded = false,
+): Promise {
+ return this.put('/api/v1/auth/me/memory', {
+ memory,
+ mark_onboarded: markOnboarded,
+ });
+}
+
+async normalizeOnboarding(
+ inputs: Record,
+): Promise> {
+ const res = await this.post('/api/v1/auth/onboarding/normalize', { inputs });
+ return res.normalized;
+}
+
+async resetOnboarding(): Promise {
+ await this.post('/api/v1/auth/me/onboarding/reset', {});
+}
+```
+
+Use the existing `this.put()` / `this.post()` helpers (they handle auth headers and camelCase/snakeCase conversion).
+
+**Done signal:** All three methods exist on AuthManager.
+
+---
+
+### TASK 2.7: Extend tRPC authRouter
+
+**File:** `adiuvAI/src/main/router/index.ts`
+
+In the `authRouter`, add these procedures:
+
+```ts
+updateMemory: t.procedure
+ .input(z.object({
+ memory: z.record(z.string(), z.string()),
+ markOnboarded: z.boolean().optional().default(false),
+ }))
+ .mutation(async ({ input }) => {
+ return authManager.updateMemory(input.memory, input.markOnboarded);
+ }),
+
+normalizeOnboarding: t.procedure
+ .input(z.object({
+ inputs: z.record(z.string(), z.string()),
+ }))
+ .mutation(async ({ input }) => {
+ return authManager.normalizeOnboarding(input.inputs);
+ }),
+
+resetOnboarding: t.procedure
+ .mutation(async () => {
+ return authManager.resetOnboarding();
+ }),
+```
+
+Also, in the existing `auth.status` procedure, add **silent auto-seeding** logic:
+- After fetching the profile, if `getFormatPrefs()` returns null → call `setFormatPrefs(detectFormatPrefs())`.
+- If `profile.memory.language` is missing/empty → call `authManager.updateMemory({ language: detectLanguage() })` silently (fire-and-forget, don't block the return).
+
+**Done signal:** Three new procedures exist. Status procedure auto-seeds format prefs and language.
+
+---
+
+### TASK 2.8: Add settings routes for formatPrefs
+
+**File:** `adiuvAI/src/main/router/index.ts`
+
+In `settingsRouter` (or create one if it doesn't exist), add:
+
+```ts
+getFormatPrefs: t.procedure.query(() => {
+ return getFormatPrefs();
+}),
+
+setFormatPrefs: t.procedure
+ .input(z.object({
+ timezone: z.string(),
+ dateFormat: z.string(),
+ timeFormat: z.enum(['12h', '24h']),
+ }))
+ .mutation(({ input }) => {
+ setFormatPrefs(input);
+ return input;
+ }),
+```
+
+**Done signal:** Both procedures exist.
+
+---
+
+### TASK 2.9: Frontend lint check
+
+Run: `cd adiuvAI && npx eslint . --fix`
+
+Fix any TypeScript/ESLint issues before proceeding to Phase 3.
+
+**Done signal:** ESLint exits 0.
+
+---
+
+## PHASE 3 — Electron Renderer (adiuvAI/src/renderer/)
+
+### TASK 3.1: Create onboarding chip options
+
+**File:** `adiuvAI/src/renderer/components/onboarding/onboardingOptions.ts` (new)
+
+```ts
+export const JOB_ROLES = ['Developer', 'Designer', 'Consultant', 'Founder', 'Project Manager'] as const;
+export const INDUSTRIES = ['Tech', 'Design', 'Consulting', 'Legal', 'Marketing', 'Education'] as const;
+export const USE_CASES = ['Solo freelancer', 'Client manager', 'Team lead', 'Personal productivity'] as const;
+export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'] as const;
+```
+
+**Done signal:** File exists with all four arrays exported.
+
+---
+
+### TASK 3.2: Create OnboardingFlow component
+
+**File:** `adiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsx` (new)
+
+This is the most complex file. Key requirements:
+
+**State machine:**
+```ts
+type Step = 'welcome' | 'jobRole' | 'industry' | 'useCase' | 'tone' | 'language' | 'reviewing' | 'done';
+```
+
+**Props:**
+```ts
+interface OnboardingFlowProps {
+ profile: UserProfile;
+}
+```
+
+**Visual style — must match AIChatPanel:**
+- Chat bubble layout: AI messages in `rounded-2xl` bubbles with a Sparkles icon (from lucide-react).
+- Use glassmorphism: `bg-white/5 backdrop-blur-md border border-white/10`.
+- Spring transitions (framer-motion) for each step entering.
+- Use shadcn components: `Button`, `Input`, `Card`.
+
+**Each wizard step shows:**
+1. An "AI bubble" with the question text.
+2. 3–6 preset chip buttons (from onboardingOptions.ts).
+3. An optional "Type your own" text input (for `job_role` and `industry` only).
+4. A "Skip" link at the bottom.
+5. Previous answers appear above as "user bubbles" (right-aligned).
+
+**Step details:**
+| Step | Question | Chips | Free text? |
+|------|----------|-------|-----------|
+| welcome | "Hi {name}! I'm your AI assistant. Let me learn a few things about you so I can help better." | Just a "Let's go" button | No |
+| jobRole | "What's your role?" | JOB_ROLES | Yes |
+| industry | "What industry do you work in?" | INDUSTRIES | Yes |
+| useCase | "How will you mainly use adiuvAI?" | USE_CASES | No |
+| tone | "How should I talk to you?" | TONES | No |
+| language | "I'll respond in {detected}. Want to change it?" | Show detected language pre-selected; allow typing a different one | Yes |
+| reviewing | Review screen (see below) | — | — |
+| done | Redirect (never renders) | — | — |
+
+**Reviewing step logic:**
+1. Partition answers: chip selections (already clean) vs free-text answers (need normalization).
+2. If free-text map is non-empty → call `trpc.auth.normalizeOnboarding.useMutation`. Show "Tidying up…" spinner on those fields only.
+3. Show a Card titled "Here's what I'll save" with all 5 fields as rows.
+4. Each row has an Edit pencil icon → converts to inline input → Enter saves → back to read-only.
+5. If LLM changed a value, show grey hint: `auto-tidied from "original text"`.
+6. Primary button: "Looks good — save" → calls `trpc.auth.updateMemory.useMutation({ memory: finalMap, markOnboarded: true })` → `utils.auth.status.invalidate()`.
+7. Secondary link: "Back to wizard" → resets to `jobRole` step with values pre-filled.
+8. **Failure modes:**
+ - Normalization fails → show raw values + banner "Couldn't auto-tidy — review and save". Save still works.
+ - Save fails → toast error, stay on review screen.
+
+**Skip behavior:** Clicking Skip on any step → calls `updateMemory({}, markOnboarded: true)` with empty map → wizard closes. Language was already auto-seeded by `auth.status`.
+
+**Done signal:** Component file exists, renders a multi-step wizard, handles reviewing + save.
+
+---
+
+### TASK 3.3: Gate OnboardingFlow in AppShell
+
+**File:** `adiuvAI/src/renderer/components/layout/AppShell.tsx`
+
+After the `authenticated === false` → `LoginForm` branch, add:
+```tsx
+if (
+ authStatusQuery.data?.profile &&
+ authStatusQuery.data.profile.onboardingCompletedAt == null
+) {
+ return ;
+}
+```
+
+Import `OnboardingFlow` from `../onboarding/OnboardingFlow`.
+
+**Done signal:** AppShell conditionally renders OnboardingFlow when onboardingCompletedAt is null.
+
+---
+
+### TASK 3.4: Add 'profile' to settings sections
+
+**File:** `adiuvAI/src/renderer/components/settings/types.ts`
+
+Add `'profile'` to the `SectionId` type and `{ id: 'profile', label: 'Profile' }` to `SECTIONS` array — insert it before `'account'`.
+
+**Done signal:** SECTIONS includes 'profile' as the first or second entry.
+
+---
+
+### TASK 3.5: Create ProfileSection component
+
+**File:** `adiuvAI/src/renderer/components/settings/ProfileSection.tsx` (new)
+
+Plain form (no chat aesthetic — this is Settings, not the wizard). Two cards:
+
+**Card 1 — "About you"** (writes to MemoryCore via `auth.updateMemory`):
+- Fields: job_role, industry, primary_use_case (select from USE_CASES), tone_preference (select from TONES), language (text input).
+- Pre-populate from `authStatusQuery.data.profile.memory`.
+- Save button → `trpc.auth.updateMemory.useMutation`.
+- "Re-run onboarding" button → `trpc.auth.resetOnboarding.useMutation` → `utils.auth.status.invalidate()` (triggers wizard via AppShell gate).
+
+**Card 2 — "Display preferences"** (writes to electron-store via `trpc.settings.setFormatPrefs`):
+- Timezone: searchable select, populated from `Intl.supportedValuesOf('timeZone')`.
+- Date format: select with options: `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`.
+- Time format: radio group — 12h / 24h.
+- Pre-populate from `trpc.settings.getFormatPrefs` query.
+- Save button → `trpc.settings.setFormatPrefs.useMutation`.
+
+Use shadcn `Card`, `Input`, `Select`, `Button`, `Label`, `RadioGroup` components.
+
+**Done signal:** Component exists with both cards, save functionality wired.
+
+---
+
+### TASK 3.6: Wire ProfileSection into settings route
+
+**File:** `adiuvAI/src/renderer/routes/settings.tsx`
+
+Add the import and the conditional render:
+```tsx
+{section === 'profile' && }
+```
+
+**Done signal:** ProfileSection renders when section=profile.
+
+---
+
+### TASK 3.7: Final lint check
+
+Run both:
+```bash
+cd api && ruff check . --fix
+cd adiuvAI && npx eslint . --fix
+```
+
+**Done signal:** Both exit 0.
+
+---
+
+## PHASE 4 — Completion
+
+### TASK 4.1: Verify all files exist
+
+Check that these files exist:
+- [ ] `api/alembic/versions/*_add_onboarding_completed_at.py`
+- [ ] `adiuvAI/src/main/auth/locale-defaults.ts`
+- [ ] `adiuvAI/src/main/api/format-row.ts`
+- [ ] `adiuvAI/src/renderer/components/onboarding/onboardingOptions.ts`
+- [ ] `adiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsx`
+- [ ] `adiuvAI/src/renderer/components/settings/ProfileSection.tsx`
+
+Check that these files were modified:
+- [ ] `api/app/models.py` (has `onboarding_completed_at`)
+- [ ] `api/app/schemas.py` (UserProfile has `memory` + `onboarding_completed_at`)
+- [ ] `api/app/api/middleware/auth.py` (get_current_user returns memory)
+- [ ] `api/app/api/routes/auth.py` (3 new routes)
+- [ ] `adiuvAI/src/shared/api-types.ts` (UserProfileSchema extended)
+- [ ] `adiuvAI/src/main/store.ts` (formatPrefs + helpers)
+- [ ] `adiuvAI/src/main/auth/auth-manager.ts` (3 new methods)
+- [ ] `adiuvAI/src/main/router/index.ts` (5 new procedures + auto-seed in status)
+- [ ] `adiuvAI/src/main/api/drizzle-executor.ts` (formatRow wiring)
+- [ ] `adiuvAI/src/renderer/components/layout/AppShell.tsx` (onboarding gate)
+- [ ] `adiuvAI/src/renderer/components/settings/types.ts` (profile section)
+- [ ] `adiuvAI/src/renderer/routes/settings.tsx` (ProfileSection render)
+
+### TASK 4.2: Output completion promise
+
+If everything above is done and lint passes:
+
+```
+ONBOARDING COMPLETE
+```
+
+---
+
+## REFERENCE — Existing patterns to reuse
+
+**DO NOT reinvent these. Copy their patterns:**
+
+| Pattern | Source file | Reuse for |
+|---------|-----------|-----------|
+| Chat bubble + Sparkles + glass | `src/renderer/components/ai/AIChatPanel.tsx` | OnboardingFlow bubbles |
+| Stepper state machine | `InlineAgentCreationStepper` in renderer | Wizard step transitions |
+| MemoryMiddleware.update_core | `api/app/core/memory_middleware.py:137-173` | PUT /me/memory route |
+| get_llm() | `api/app/core/llm.py` | Normalize route |
+| electron-store helpers | `src/main/store.ts` (getDeviceId pattern) | getFormatPrefs/setFormatPrefs |
+| tRPC procedure pattern | `src/main/router/index.ts` (auth.status) | New procedures |
+| shadcn form components | Existing settings sections | ProfileSection |
+| toCamelCase / toSnakeCase | `auth-manager.ts` proxy helpers | Automatic key conversion |
+
+---
+
+## DO NOT
+
+- Add features not described here (no avatar upload, no i18n framework, no animation library beyond framer-motion if already installed).
+- Modify the orchestrator or system prompts — MemoryCore injection is already handled.
+- Add foreign key constraints to the migration.
+- Store formatting prefs in MemoryCore.
+- Let the LLM normalization route throw on failure — it MUST return inputs unchanged.
+- Skip the reviewing step in the wizard.
+- Run both lint checks and fix issues before claiming completion.
diff --git a/docs/plan-onboarding-wizard.md b/docs/plan-onboarding-wizard.md
new file mode 100644
index 0000000..eca349b
--- /dev/null
+++ b/docs/plan-onboarding-wizard.md
@@ -0,0 +1,312 @@
+# First-Run User Onboarding (Profile → Core Memory + Format Prefs)
+
+## Context
+
+Today, after sign-up or login, users land directly on the home chat with no introduction. Signup only collects `name`, `surname`, `email`. The backend AI agents have no idea who they're talking to — generic answers, generic tone, no language match, raw timestamps.
+
+This change adds a one-time wizard that runs the **first time a user opens the app post-login**. It:
+1. Seeds the user's **core memory** (encrypted, server-side) with personalization data the AI should reason about (`job_role`, `industry`, `primary_use_case`, `tone_preference`, `language`).
+2. Auto-detects and stores **formatting preferences** (`timezone`, `time_format`, `date_format`) **on the FE** as electron-store settings — *not* in core memory, because the LLM should never see raw timestamps or have to reason about format strings. Instead, the FE applies these to tool-result rows before they're sent back to the backend.
+3. Optionally normalizes user free-text answers via a single backend LLM call before persisting, so messy inputs like "i build websites" become clean values like "Web Developer".
+
+Because [memory_middleware.py:53-94](api/app/core/memory_middleware.py#L53-L94) already auto-injects `core_memory` into every orchestrator call (see [device_ws.py:213](api/app/api/routes/device_ws.py#L213) and [device_ws.py:282](api/app/api/routes/device_ws.py#L282)), no system-prompt code changes — writing to `MemoryCore` is enough for agents to "see" the data on their next call.
+
+**Decisions made with the user:**
+- **UI style**: hybrid chat-styled wizard (looks like AIChatPanel — bubbles, chips — but pre-scripted, no LLM calls per step)
+- **Storage split**: AI-relevant fields in encrypted `MemoryCore`; formatting prefs in FE-local electron-store
+- **Skippable + editable** in a new Settings → Profile section
+- **OS-derived defaults**: language, timezone, time format, date format auto-detected from the OS. Language is shown in the wizard for confirmation; the three formatting prefs are seeded silently and editable in Settings.
+- **Avatar**: comes from Google OAuth (already supported via `users.avatar_url`). Not in this wizard. A manual upload control in Settings → Profile is a nice-to-have but **out of scope** for this change.
+- **LLM normalization**: yes, but only on free-text answers, in **one batch call at the final 'Done' step**. User sees a "Here's what I saved" review screen and can edit before persisting.
+
+## Fields collected
+
+| Key | Lives in | Source | In wizard? | Editable in Settings? | Used by |
+|---------------------|---------------------------|------------------------------|------------------------|----------------------|------------|
+| `job_role` | `MemoryCore` (BE, encrypted) | User (chip + free text) | Yes | Yes | LLM |
+| `industry` | `MemoryCore` | User (chip + free text) | Yes | Yes | LLM |
+| `primary_use_case` | `MemoryCore` | User (chip) | Yes | Yes | LLM |
+| `tone_preference` | `MemoryCore` | User (chip) | Yes | Yes | LLM |
+| `language` | `MemoryCore` | OS `app.getLocale()` → user confirms | Yes — confirm/change | Yes | LLM (response language) + future UI i18n |
+| `timezone` | electron-store (FE) | `Intl.DateTimeFormat().resolvedOptions().timeZone` | No — silent | Yes | FE formatter |
+| `time_format` | electron-store (FE) | Derived from locale (12h/24h)| No — silent | Yes | FE formatter |
+| `date_format` | electron-store (FE) | Derived from locale (e.g. dd/MM/yyyy) | No — silent | Yes | FE formatter |
+
+The split is the load-bearing decision: **the LLM never sees raw timestamps or format prefs**. Instead, the FE's drizzle executor formats every timestamp column in tool-result rows using the user's preferences before sending the `tool_result` frame back to the backend.
+
+---
+
+## Architecture notes
+
+1. **AI orchestration is fully delegated to the backend** via WebSocket — see [orchestrator.ts:87-117](adiuvAI/src/main/ai/orchestrator.ts#L87-L117). The Electron client never builds a system prompt. So all LLM-relevant personalization must live on the backend (in `MemoryCore`).
+
+2. **Tool calls are FE-executed**: backend sends `WsToolCall` → FE [drizzle-executor.ts](adiuvAI/src/main/api/drizzle-executor.ts) runs the SELECT and returns `{ rows }` → FE [backend-client.ts:652-658](adiuvAI/src/main/api/backend-client.ts#L652-L658) wraps it as a `tool_result` frame. The formatting hook goes **between the executor returning rows and the frame being sent** — this is where raw `dueDate` numbers become `"15/04/2026 14:30"` strings.
+
+3. **Format prefs are per-device**: `timezone` is inherently per-device (your laptop and phone may be in different cities). For consistency we keep all three format prefs FE-local. If the user wants cross-device sync later, this can migrate to `MemoryCore` without breaking the wire format — but that's not v1.
+
+---
+
+## Files to change
+
+### Backend (`api/`)
+
+1. **`api/alembic/versions/_add_onboarded_flag.py`** — new Alembic migration:
+ - `ALTER TABLE users ADD COLUMN onboarding_completed_at TIMESTAMPTZ NULL`
+ The five LLM-relevant values live in the existing `memory_core` table — no new columns.
+
+2. **[api/app/models.py:63-94](api/app/models.py#L63-L94)** — add `onboarding_completed_at: Mapped[datetime | None]` to `User`.
+
+3. **[api/app/schemas.py:27-33](api/app/schemas.py#L27-L33)** — extend `UserProfile`:
+ ```python
+ class UserProfile(BaseModel):
+ id: str
+ email: str
+ name: str | None = None
+ surname: str | None = None
+ tier: BillingTier
+ avatar_url: str | None = None
+ onboarding_completed_at: int | None = None # epoch ms
+ memory: dict[str, str] = Field(default_factory=dict)
+ ```
+
+4. **[api/app/api/middleware/auth.py:74-79](api/app/api/middleware/auth.py#L74-L79)** — extend `get_current_user`:
+ - Read `onboarding_completed_at` from the user row.
+ - Use `MemoryMiddleware(db).list_core_blocks(user_id)` to load decrypted core blocks → `{label: value}` dict, attach as `memory`.
+
+5. **[api/app/api/routes/auth.py](api/app/api/routes/auth.py)** — add a new route. Do not extend `_UpdateProfileRequest` (keep name/surname separate).
+ ```python
+ class _UpdateMemoryRequest(BaseModel):
+ memory: dict[str, str] = Field(default_factory=dict)
+ mark_onboarded: bool = False
+
+ @router.put("/me/memory", response_model=UserProfile)
+ async def update_memory(
+ body: _UpdateMemoryRequest,
+ current_user: UserProfile = Depends(get_current_user),
+ db: AsyncSession = Depends(get_session),
+ ) -> UserProfile:
+ memory = MemoryMiddleware(db)
+ for key, value in body.memory.items():
+ await memory.update_core(current_user.id, key, value)
+ if body.mark_onboarded:
+ result = await db.execute(select(User).where(User.id == current_user.id))
+ user = result.scalar_one()
+ user.onboarding_completed_at = datetime.now(timezone.utc)
+ await db.commit()
+ # Re-fetch via get_current_user-style logic and return UserProfile.
+ ```
+
+6. **`api/app/api/routes/auth.py`** — new normalization route:
+ ```python
+ class _NormalizeRequest(BaseModel):
+ inputs: dict[str, str] # e.g. {"job_role": "i build websites"}
+
+ class _NormalizeResponse(BaseModel):
+ normalized: dict[str, str]
+
+ @router.post("/onboarding/normalize", response_model=_NormalizeResponse)
+ async def normalize_onboarding(
+ body: _NormalizeRequest,
+ current_user: UserProfile = Depends(get_current_user),
+ ) -> _NormalizeResponse:
+ """One-shot LLM normalization for free-text onboarding answers."""
+ ```
+ Implementation: build a small system prompt ("You normalize user onboarding answers. Return JSON only. Each key maps to a clean, ≤3-word canonical label."), call `get_llm("gpt-4o-mini", temperature=0)` from [api/app/core/llm.py](api/app/core/llm.py) with `response_format={"type": "json_object"}`, parse, return. Must short-circuit and return the inputs unchanged on any LLM error so the wizard never blocks on a flaky model call. Rate-limited by the existing `TierRateLimiter` middleware.
+
+7. **No orchestrator / prompt changes needed.** `MemoryMiddleware.enrich_context()` already injects `core_memory` into every chat call. **This is the whole point of using `MemoryCore` instead of system-prompt injection.**
+
+### Electron main (`adiuvAI/src/main/`)
+
+8. **[src/shared/api-types.ts:26-34](adiuvAI/src/shared/api-types.ts#L26-L34)** — extend `UserProfileSchema` with `onboardingCompletedAt: z.number().int().nullable().optional()` and `memory: z.record(z.string(), z.string()).default({})`.
+
+9. **[src/main/store.ts:23-38](adiuvAI/src/main/store.ts#L23-L38)** — add a `formatPrefs` block to `AppSettings`:
+ ```ts
+ formatPrefs: {
+ timezone: string; // 'Europe/Rome'
+ dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
+ timeFormat: '12h' | '24h';
+ } | null; // null = not yet seeded
+ ```
+ Default to `null`. Add helpers `getFormatPrefs()` and `setFormatPrefs(prefs)`.
+
+10. **New: `src/main/auth/locale-defaults.ts`** — small helper:
+ ```ts
+ export function detectFormatPrefs(): FormatPrefs {
+ const locale = app.getLocale(); // 'it-IT'
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const timeFormat = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 ? '12h' : '24h';
+ const dateFormat = inferDateFormatFromLocale(locale); // small lookup: 'en-US'→MM/dd/yyyy, 'en-GB'/'it-IT'/...→dd/MM/yyyy, 'ja-JP'/...→yyyy-MM-dd
+ return { timezone, timeFormat, dateFormat };
+ }
+ export function detectLanguage(): string { return app.getLocale(); } // 'it-IT'
+ ```
+
+11. **New: `src/main/api/format-row.ts`** — pure function called by the executor:
+ ```ts
+ const TIMESTAMP_COLUMNS = new Set([
+ 'createdAt', 'updatedAt', 'dueDate', 'date', 'endDate',
+ 'lastRunAt', 'startedAt', 'completedAt',
+ ]);
+
+ export function formatRow>(row: T, prefs: FormatPrefs): T;
+ export function formatRows>(rows: T[], prefs: FormatPrefs): T[];
+ ```
+ For each known timestamp column whose value is a `number`, replace it with `formatInstant(value, prefs)` where `formatInstant` uses `Intl.DateTimeFormat(locale, { timeZone: prefs.timezone, hour12: prefs.timeFormat === '12h', ... })` and the `dateFormat` setting. Returns a new object — does not mutate.
+
+ The set of timestamp columns is hard-coded against the Drizzle schema; if a new timestamp column is added, this set must be updated. (Acceptable for v1 — the schema is small. If it grows, we can derive the set from the Drizzle schema's `integer('...', { mode: 'number' })` columns at startup.)
+
+12. **[src/main/api/drizzle-executor.ts:204-263](adiuvAI/src/main/api/drizzle-executor.ts#L204-L263)** — wrap the executor's `select`/`get`/`insert`/`update` return paths so that `rows`/`row` get passed through `formatRow(s)(..., getFormatPrefs() ?? detectFormatPrefs())`. The `?? detect…` fallback handles the edge case where the executor runs before the first auth.status seed call (e.g. background tool calls during login).
+
+13. **[src/main/auth/auth-manager.ts:170-174](adiuvAI/src/main/auth/auth-manager.ts#L170-L174)** — add two methods:
+ ```ts
+ async updateMemory(memory: Record, markOnboarded = false): Promise
+ async normalizeOnboarding(inputs: Record): Promise>
+ ```
+ Both call the new backend routes via the existing `put`/`post` helpers.
+
+14. **[src/main/router/index.ts:1059-1098](adiuvAI/src/main/router/index.ts#L1059-L1098)** — extend `authRouter`:
+ - Add `auth.updateMemory` mutation: input `{ memory, markOnboarded? }`.
+ - Add `auth.normalizeOnboarding` mutation: input `{ inputs: Record }`.
+ - **Extend `auth.status`** so that immediately after fetching the profile, if `getFormatPrefs()` is `null`, it calls `setFormatPrefs(detectFormatPrefs())` (silent FE seed). If `profile.memory.language` is missing, it also calls `authManager.updateMemory({ language: detectLanguage() })` (silent BE seed). Both run only on first launch — subsequent calls find the values present and short-circuit.
+
+### Electron renderer (`adiuvAI/src/renderer/`)
+
+15. **[src/renderer/components/layout/AppShell.tsx:79-119](adiuvAI/src/renderer/components/layout/AppShell.tsx#L79-L119)** — add the first-run gate. After the `authStatusQuery.data?.authenticated === false` branch:
+ ```tsx
+ if (authStatusQuery.data?.profile && authStatusQuery.data.profile.onboardingCompletedAt == null) {
+ return ;
+ }
+ ```
+
+16. **New: `src/renderer/components/onboarding/OnboardingFlow.tsx`** — the wizard. Internal state machine:
+ ```ts
+ type Step = 'welcome' | 'jobRole' | 'industry' | 'useCase' | 'tone' | 'language' | 'reviewing' | 'done';
+ ```
+ Renders chat-bubble layout matching [AIChatPanel.tsx](adiuvAI/src/renderer/components/ai/AIChatPanel.tsx) — Sparkles icon, `rounded-2xl`, glassmorphism, spring transitions per the design context in `adiuvAI/.claude/CLAUDE.md`. Each step shows an "AI" bubble with the question, 3–6 chip presets, an optional "type your own" input, and a Skip link.
+
+ The `language` step pre-selects the value already in `profile.memory.language` (auto-seeded). User confirms or picks a different one.
+
+ **`reviewing` step** (the LLM normalization gate):
+ - On entry, partition the user's answers into two groups:
+ - **Chip selections** — already canonical, skip the LLM entirely.
+ - **Free-text answers** — bundle into a `{key: rawText}` map.
+ - If the free-text map is non-empty, call `trpc.auth.normalizeOnboarding.useMutation` with it. Show a small inline loader on those fields only ("Tidying up…", ~1-2s).
+
+ **Review screen UX** — single card titled "Here's what I'll save", listing all five fields as rows:
+
+ | Row appearance | When |
+ |---------------------------------|---------------------------------------------------|
+ | Read-only label + value | Chip-selected values (`use_case`, `tone`, etc.) — checkmark icon |
+ | Read-only label + value + small grey hint `auto-tidied from "i build websites"` | Free-text values that the LLM normalized |
+ | Read-only label + value | Free-text values that the LLM did NOT change |
+
+ Each row has a small **Edit** pencil icon on the right. Clicking it converts that row in-place into a text input (or a Select for chip-based fields like `tone`/`use_case`, populated with the same chip presets plus a free-text "Other" option). The user types the new value, presses Enter or clicks Save → the row goes back to read-only with the **new value as-typed**.
+
+ **Edited values are stored verbatim — no re-normalization.** Rationale: the LLM normalization exists to clean up the *initial* messy answer; once the user has seen the suggestion and chosen to override it, re-running the LLM would either no-op (their text is already clean) or fight them. The user is the final arbiter. The "auto-tidied from…" hint disappears once a row is edited (the new value is no longer LLM-derived).
+
+ Bottom of the card: a single primary **"Looks good — save"** button → calls `trpc.auth.updateMemory.useMutation` with the final map + `markOnboarded: true` → `utils.auth.status.invalidate()` → AppShell remounts into the normal app. A secondary **"Back to wizard"** link drops the user back to the first wizard step (`jobRole`) with all current values pre-filled — used when the review reveals the answers are wrong enough that re-running the wizard is faster than five inline edits.
+
+ **Failure modes**:
+ - Normalization HTTP call fails → review screen shows raw values with a small banner "Couldn't auto-tidy — review and save". Save still works.
+ - User clicks "Looks good — save" and `updateMemory` fails → toast error, user stays on the review screen, can retry.
+
+ **Skip behaviour**: clicking Skip on any step calls `updateMemory({}, markOnboarded: true)` — empty map, just the flag. We don't re-prompt next launch.
+
+17. **New: `src/renderer/components/onboarding/onboardingOptions.ts`** — preset chip lists:
+ ```ts
+ export const JOB_ROLES = ['Developer', 'Designer', 'Consultant', 'Founder', 'Project Manager'];
+ export const INDUSTRIES = ['Tech', 'Design', 'Consulting', 'Legal', 'Marketing', 'Education'];
+ export const USE_CASES = ['Solo freelancer', 'Client manager', 'Team lead', 'Personal productivity'];
+ export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'];
+ ```
+
+18. **[src/renderer/components/settings/types.ts:3-9](adiuvAI/src/renderer/components/settings/types.ts#L3-L9)** — add `'profile'` to `SectionId` and `{ id: 'profile', label: 'Profile' }` to `SECTIONS` (before `'account'`).
+
+19. **New: `src/renderer/components/settings/ProfileSection.tsx`** — Settings → Profile editor. Plain form (no chat aesthetic in Settings). Two cards:
+ - **"About you"** (writes to `MemoryCore` via `auth.updateMemory`): job_role, industry, primary_use_case, tone_preference, language. "Re-run onboarding" button → small backend route `POST /auth/onboarding/reset` (or just an extension of `update_memory` with `clear_onboarded: true`) that nulls `users.onboarding_completed_at`, then `auth.status.invalidate()` remounts the wizard.
+ - **"Display preferences"** (writes to electron-store via a new `trpc.settings.setFormatPrefs` mutation): timezone (select populated from `Intl.supportedValuesOf('timeZone')`), date_format (select: dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd), time_format (radio: 12h / 24h).
+
+20. **[src/main/router/index.ts](adiuvAI/src/main/router/index.ts)** — `settingsRouter`: add `getFormatPrefs` query and `setFormatPrefs` mutation that read/write to electron-store via `getFormatPrefs()` / `setFormatPrefs()`.
+
+21. **[src/renderer/routes/settings.tsx:55-58](adiuvAI/src/renderer/routes/settings.tsx#L55-L58)** — add `{section === 'profile' && }`.
+
+---
+
+## Patterns to reuse (do not duplicate)
+
+- **Stepper state**: `InlineAgentCreationStepper` — `useState<...>` plus conditional rendering.
+- **Chat bubble aesthetic**: copy bubble + Sparkles + glass styling from [AIChatPanel.tsx](adiuvAI/src/renderer/components/ai/AIChatPanel.tsx). Do **not** invent a new chat shell.
+- **Form components**: shadcn `Field`/`Input`/`Select`/`Button`/`Card` already used in existing settings sections.
+- **`MemoryMiddleware.update_core`** ([memory_middleware.py:137-173](api/app/core/memory_middleware.py#L137-L173)) — already used by `deep_agent.py:343`. We just expose it via REST.
+- **`get_llm()` from [api/app/core/llm.py](api/app/core/llm.py)** — for the normalization route. Use `gpt-4o-mini` with `temperature=0` and JSON response format.
+- **`toCamelCase` / `toSnakeCase`** in `auth-manager` — handles `mark_onboarded` ↔ `markOnboarded` automatically.
+- **electron-store helpers** ([store.ts:62-98](adiuvAI/src/main/store.ts#L62-L98)) — same pattern as `getDeviceId` / `getLocalAgents`.
+
+---
+
+## Verification
+
+1. **Backend migration + tests**:
+ ```
+ cd api && alembic upgrade head
+ pytest tests/test_auth.py -k "memory or normalize"
+ ```
+ Manually `curl PUT /api/v1/auth/me/memory` with `{"memory": {"job_role":"Developer"}, "mark_onboarded": true}` and confirm round-trip via `GET /api/v1/auth/me`.
+
+2. **LLM normalization route**:
+ - `curl POST /api/v1/auth/onboarding/normalize` with `{"inputs": {"job_role": "i build websites", "industry": "tech-ish stuff"}}`.
+ - Expect `{"normalized": {"job_role": "Web Developer", "industry": "Technology"}}` (or similar — exact phrasing varies).
+ - Stop the LLM provider (or use an invalid `OPENAI_API_KEY`) and re-run — must return inputs unchanged, never 500.
+
+3. **Locale auto-seed (FE + BE)**:
+ - Fresh user, fresh electron-store. Log in via Electron.
+ - `getFormatPrefs()` should now return the detected `{timezone, dateFormat, timeFormat}`.
+ - `memory_core` should have one row: `language`.
+ - Reload app → no second seed call (idempotent).
+
+4. **First-run wizard (golden path with chips only)**:
+ - Reset: backend `UPDATE users SET onboarding_completed_at=NULL; DELETE FROM memory_core WHERE user_id=...`. FE: clear electron-store `formatPrefs`.
+ - `npm start` → log in → land on `OnboardingFlow`.
+ - Pick a chip on every step (no free text). Confirm language. Land on review screen — should not show a loading spinner (no normalization needed). Click Confirm.
+ - `auth.status` invalidates, AppShell mounts the home chat.
+ - `SELECT key FROM memory_core WHERE user_id=...` → 5 keys (job_role, industry, primary_use_case, tone_preference, language).
+ - Reload app → does not re-prompt.
+
+5. **First-run wizard (free-text path)**:
+ - Reset. Walk through wizard typing free text on `job_role` and `industry` (e.g. "i build websites", "tech-ish stuff").
+ - On final step, see ~1-2s "Tidying up…" spinner, then a review screen showing the normalized values plus the chip-selected use_case/tone/language.
+ - Edit one normalized value manually. Confirm. The edited value is what lands in `memory_core`.
+
+6. **AI uses the data (proof the wiring works)**:
+ - With an onboarded user whose `tone_preference="Formal"` and `language="it-IT"`, ask the home chat "draft a quick status email".
+ - Response should be in Italian and read formal. **If language doesn't match, `enrich_context` is not feeding `core_memory` into the prompt as expected — investigate before declaring done.** This is the single most likely failure point because we don't modify it.
+
+7. **Format prefs reach the LLM as strings**:
+ - From the home chat, ask "what tasks are due this week?".
+ - Inspect the network/log of the `tool_result` frame the FE sends back. Every `dueDate` field must be a formatted string like `"15/04/2026 14:30"`, not a numeric timestamp.
+ - The AI's response must reference dates in the user's preferred format.
+ - Change `time_format` from 24h to 12h in Settings → Profile. Re-ask. Times should now be `2:30 PM` style.
+
+8. **Skip flow**:
+ - Reset, log in, click Skip on step 1.
+ - `users.onboarding_completed_at` set; `memory_core` only has `language` (from auto-seed).
+ - Reload → no re-prompt.
+
+9. **Re-run onboarding**: Settings → Profile → "Re-run onboarding" → wizard mounts immediately.
+
+10. **Lint**:
+ ```
+ cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint
+ cd api && ruff check .
+ ```
+
+---
+
+## Out of scope (deferred)
+
+- **UI internationalisation framework**: storing `language` enables future i18n, but no translation library is added. Wizard copy hardcoded in English for v1.
+- **Avatar upload control** in Settings → Profile: avatar already comes from Google OAuth via `users.avatar_url`. A manual upload UI is a nice-to-have follow-up.
+- **Working hours**, **top goals** (free-text seeds): same `MemoryCore` pattern — easy to add later.
+- **Cross-device sync of format prefs**: v1 stores them per-device in electron-store. Migrating to `MemoryCore` later doesn't break the wire format.
+- **Schema-bump re-prompting**: when we add a new wizard question later we'll need a `core_memory["__onboarding_version__"]` key and a guard. Not needed now.
+- **Animated typing effect** on AI bubbles.
diff --git a/waitlist b/waitlist
new file mode 160000
index 0000000..d32fc7a
--- /dev/null
+++ b/waitlist
@@ -0,0 +1 @@
+Subproject commit d32fc7ae3091ad9cd3410e21201f0b3289f135fa
diff --git a/website b/website
index b06b1fb..7da1f58 160000
--- a/website
+++ b/website
@@ -1 +1 @@
-Subproject commit b06b1fb1d58144a2e5e6334925c86fe40f73e725
+Subproject commit 7da1f5811e36d48d19e16a4a55c3f45c62044685