# 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.