---
phase: 30-hardware-detection-mode-selection
verified: 2026-04-02T23:58:20Z
status: human_needed
score: 11/11 must-haves verified
human_verification:
  - test: "Visual walkthrough of 3-step onboarding wizard"
    expected: "Step 1 shows skeleton then hardware info; Step 2 shows three mode cards with 'Both (recommended)' pre-selected with blue border; Step 3 shows root directory input; Back navigation works between steps; wizard closes after Get Started"
    why_human: "Visual rendering, interaction flow, and selected state styling cannot be verified programmatically"
  - test: "Privacy frame appears for GPU and Apple Silicon tiers"
    expected: "On a machine with GPU or Apple Silicon, 'Local AI (recommended for privacy)' copy and 'Runs entirely on your machine. No accounts. No tracking. Works offline.' appears below hardware stats"
    why_human: "Tier detection depends on runtime hardware; test environment is linux/cpu_only so only the cpu_only path runs in CI"
  - test: "Mode selection persists to disk on wizard completion"
    expected: "After completing onboarding with 'Personal AI Assistant' selected, data/nexus-settings.json contains { mode: 'personal_ai' }"
    why_human: "Requires running the full wizard flow end-to-end with a real server instance"
---

# Phase 30: Hardware Detection + Mode Selection — Verification Report

**Phase Goal:** Users see accurate hardware information during onboarding, get a model recommendation matched to their machine, and choose a mode that correctly gates all downstream features — with the probe working before board auth exists
**Verified:** 2026-04-02T23:58:20Z
**Status:** human_needed
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| #  | Truth | Status | Evidence |
|----|-------|--------|----------|
| 1  | GET /api/system/providers returns 200 with hardware info without any auth token | ✓ VERIFIED | `hardwareRoutes()` mounted with `app.use("/api", hardwareRoutes())` at line 132 of app.ts, BEFORE `const api = Router()` at line 135 and `api.use(boardMutationGuard())` at line 136 |
| 2  | Apple Silicon is detected via CPU brand string and returns unifiedMemory: true with hardwareTier: apple_silicon | ✓ VERIFIED | hardware.ts line 45: `platform === "darwin" && cpuModel?.startsWith("Apple")` — returns `unifiedMemory: true, hardwareTier: "apple_silicon"`. Test confirms: `returns apple_silicon tier when platform is darwin and CPU starts with Apple` |
| 3  | GPU detection via systeminformation has a 3-second timeout; failure degrades to cpu_only tier | ✓ VERIFIED | hardware.ts lines 68-71: `Promise.race([si.graphics(), new Promise<never>((_resolve, reject) => { setTimeout(() => reject(...), 3000) })])`. Catch block sets `hardwareTier = "cpu_only"`. Test confirms timeout scenario passes |
| 4  | nexusSettingsService persists mode to data/nexus-settings.json and reads it back | ✓ VERIFIED | nexus-settings.ts: `set()` writes to `resolvePaperclipInstanceRoot()/data/nexus-settings.json`, `get()` reads it back with Zod validation and graceful default of `{ mode: "both" }`. Tests confirm CRUD round-trip |
| 5  | PATCH /api/nexus/settings requires board auth and persists the mode value | ✓ VERIFIED | nexus-settings route lines 23-35: `assertBoard(req)` before `nexusSettingsService().set(req.body)`. Route mounted on `api` router (inside boardMutationGuard) via `api.use(nexusSettingsRoutes())` at app.ts line 163 |
| 6  | Model catalog contains tier field on every variant and includes qwen3:8b family | ✓ VERIFIED | `node -e` check: `Total variants: 12, Missing tier: 0, Has qwen3: true` — all 12 variants have tier arrays, qwen3 family exists |
| 7  | getRecommendedModel filters by hardware tier when tier data is present | ✓ VERIFIED | ollama.ts line 186: `if (hardwareTier && entry.tier && !entry.tier.includes(hardwareTier)) continue;`. Signature accepts optional `hardwareTier?: "gpu" | "apple_silicon" | "cpu_only"`. Test confirms filtering |
| 8  | User sees hardware detection results (GPU/RAM/unified memory) during onboarding within 5 seconds | ✓ VERIFIED | HardwareSummaryStep.tsx renders tier-appropriate stat rows using `hardwareInfo` from `useHardwareInfo()` hook. Server-side 3s GPU timeout + React Query retry:1 ensures result within 5s |
| 9  | User can select mode: Personal AI Assistant, Project Builder, or Both (default pre-selected) | ✓ VERIFIED | ModeSelector.tsx renders three cards with correct labels. NexusOnboardingWizard.tsx: `useState<NexusMode>("both")` — default "both" as required |
| 10 | Selected mode is persisted via PATCH /api/nexus/settings on wizard completion | ✓ VERIFIED | NexusOnboardingWizard.tsx lines 180-184: `await updateNexusSettings({ mode: selectedMode })` called after company creation, wrapped in try/catch |
| 11 | Wizard has 3 steps: hardware detection, mode selection, root directory (existing) | ✓ VERIFIED | NexusOnboardingWizard.tsx: `useState(1)` for step, `Step {step} of 3` indicator, conditional rendering for steps 1/2/3 with Back navigation on steps 2 and 3 |

**Score:** 11/11 truths verified

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `server/src/services/hardware.ts` | hardwareService with detect() returning HardwareInfo | ✓ VERIFIED | Exports `hardwareService`, `HardwareInfo`, `HardwareTier`, `_resetHardwareCache`. 105 lines, fully implemented |
| `server/src/services/nexus-settings.ts` | File-backed nexus settings persistence | ✓ VERIFIED | Exports `nexusSettingsService`, `NexusMode`, `NEXUS_MODES`. Zod-validated, file-backed. 47 lines |
| `server/src/routes/hardware.ts` | Unauthenticated GET /api/system/providers | ✓ VERIFIED | Exports `hardwareRoutes`. Two routes: `/system/providers` and `/system/providers/recommendation`. Unauthenticated comment present |
| `server/src/routes/nexus-settings.ts` | Board-auth-gated GET/PATCH /api/nexus/settings | ✓ VERIFIED | Exports `nexusSettingsRoutes`. Both routes call `assertBoard(req)` before service calls |
| `server/src/data/ollama-model-catalog.json` | Extended model catalog with tier arrays and qwen3 family | ✓ VERIFIED | 12 variants, 0 missing tier, qwen3 family present with qwen3:8b |
| `server/src/__tests__/30-hardware-detection.test.ts` | Unit tests for hardware service, settings service, routes, and catalog | ✓ VERIFIED | 13 tests across 4 describe blocks; all pass |
| `ui/src/api/hardware.ts` | Typed fetch wrappers for hardware probe and nexus settings | ✓ VERIFIED | Exports `fetchHardwareInfo`, `fetchNexusSettings`, `updateNexusSettings` + all types. 34 lines |
| `ui/src/hooks/useHardwareInfo.ts` | useQuery wrapper for hardware data | ✓ VERIFIED | Exports `useHardwareInfo`. Uses `queryKeys.hardware.info`, 5-min staleTime, retry:1 |
| `ui/src/components/onboarding/ModeSelector.tsx` | Three-card mode selector with selected state styling | ✓ VERIFIED | Three cards, `border-primary bg-primary/5` selected state, correct labels |
| `ui/src/components/onboarding/HardwareSummaryStep.tsx` | Hardware info display with skeleton loading, tier-appropriate labels, privacy frame | ✓ VERIFIED | All three tier paths implemented, privacy frame for non-cpu_only, Skeleton loading, error state |
| `ui/src/components/NexusOnboardingWizard.tsx` | Multi-step wizard: hardware -> mode -> root directory | ✓ VERIFIED | 3-step flow, step indicator, Back navigation, mode persistence on submit, `export function OnboardingWizard()` preserved |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `server/src/routes/hardware.ts` | `server/src/services/hardware.ts` | `hardwareService().detect()` | ✓ WIRED | Line 12: `const info = await hardwareService().detect()` |
| `server/src/app.ts` | `server/src/routes/hardware.ts` | `app.use("/api", hardwareRoutes())` | ✓ WIRED | Line 132: `app.use("/api", hardwareRoutes())` — before `const api = Router()` at line 135 |
| `server/src/services/ollama.ts` | `server/src/data/ollama-model-catalog.json` | `loadCatalog()` | ✓ WIRED | `loadCatalog()` exists in ollama.ts (line 79), reads catalog JSON by resolved __dirname path |
| `ui/src/hooks/useHardwareInfo.ts` | `/api/system/providers` | fetch in useQuery | ✓ WIRED | Hook calls `fetchHardwareInfo()` from api/hardware.ts which calls `api.get("/system/providers")` |
| `ui/src/components/onboarding/HardwareSummaryStep.tsx` | `ui/src/hooks/useHardwareInfo.ts` | useHardwareInfo hook | ✓ WIRED | Props flow: `hardwareInfo`, `isLoading`, `isError` from `useHardwareInfo()` in wizard, passed down to HardwareSummaryStep |
| `ui/src/components/NexusOnboardingWizard.tsx` | `ui/src/api/hardware.ts` | updateNexusSettings on wizard complete | ✓ WIRED | Lines 180-184: `await updateNexusSettings({ mode: selectedMode })` |
| `ui/src/components/NexusOnboardingWizard.tsx` | `ui/src/components/onboarding/ModeSelector.tsx` | React component composition | ✓ WIRED | Line 250: `<ModeSelector value={selectedMode} onChange={setSelectedMode} />` |

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `HardwareSummaryStep.tsx` | `hardwareInfo` | `useHardwareInfo()` → `fetchHardwareInfo()` → `GET /api/system/providers` → `hardwareService().detect()` → `os.totalmem()`, `os.freemem()`, `si.graphics()` | Yes — live OS calls | ✓ FLOWING |
| `ModeSelector.tsx` | `value: NexusMode` | `useState<NexusMode>("both")` in wizard, updated by `onChange` | Yes — interactive state | ✓ FLOWING |
| `NexusOnboardingWizard.tsx` (persist) | `selectedMode` | `useState<NexusMode>("both")` → `updateNexusSettings({ mode: selectedMode })` → `PATCH /api/nexus/settings` → `nexusSettingsService().set()` → `writeFileSync` | Yes — writes to `data/nexus-settings.json` | ✓ FLOWING |

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| All 13 hardware tests pass | `npx vitest run src/__tests__/30-hardware-detection.test.ts` | 13/13 passed (308ms) | ✓ PASS |
| Model catalog: all variants have tier | `node -e "..."` catalog check | Total: 12, Missing tier: 0, Has qwen3: true | ✓ PASS |
| Hardware routes mounted before boardMutationGuard | grep lines 131-136 of app.ts | `app.use("/api", hardwareRoutes())` at line 132, `const api = Router()` at line 135 | ✓ PASS |
| TypeScript compilation (UI) | `pnpm --filter ui exec tsc --noEmit` | 1 pre-existing error in `AgentConfigForm.tsx` (detectModel — introduced by prior Hermes commit `1583a2d6`, not by phase 30; phase 30 files have 0 TS errors) | ⚠️ PRE-EXISTING |

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| ONBD-01 | 30-01, 30-02 | User can select mode (Personal AI Assistant / Project Builder / Both) during onboarding | ✓ SATISFIED | `ModeSelector.tsx` three-card UI; `NexusOnboardingWizard.tsx` persists via `updateNexusSettings` |
| ONBD-02 | 30-01 | System auto-detects GPU, RAM, and Apple Silicon unified memory within 5 seconds | ✓ SATISFIED | `hardwareService().detect()` with 3s GPU timeout; `HardwareSummaryStep` displays results; server + React Query retry:1 ensures < 5s total |
| ONBD-03 | 30-01 | System recommends best local model from pre-built JSON database based on detected hardware | ✓ SATISFIED | Catalog extended with `tier` arrays; `getRecommendedModel(models, ram, hardwareTier)` filters by tier; `GET /api/system/providers/recommendation` endpoint returns tier-filtered catalog |
| ONBD-07 | 30-02 | Local AI framed as privacy premium ("runs entirely on your machine, no accounts, works offline") | ✓ SATISFIED | `HardwareSummaryStep.tsx` lines 85-92: "Local AI (recommended for privacy)" + "Runs entirely on your machine. No accounts. No tracking. Works offline." shown when `hardwareTier !== "cpu_only"` |

All 4 requirement IDs from PLAN frontmatter are accounted for. No orphaned requirements found for this phase.

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `ui/src/components/AgentConfigForm.tsx` | 351 | `agentsApi.detectModel` — property does not exist on `agentsApi` type (TS2339) | ⚠️ Warning | Pre-existing error from commit `1583a2d6` (Hermes adapter upgrade); not introduced by phase 30; does not affect hardware/mode-selection functionality |

No stub patterns, empty returns, placeholder comments, or disconnected data paths found in any phase 30 artifacts.

### Human Verification Required

#### 1. Visual Walkthrough of 3-Step Onboarding Wizard

**Test:** Start dev server (`pnpm dev` in /opt/nexus), open browser to http://localhost:3100. Trigger the onboarding wizard. Walk through all 3 steps.
**Expected:**
- Step 1: Shows "Detecting your hardware..." heading with skeleton rows while probe is in-flight, transitions to "Your hardware" with real stats. Continue button always enabled.
- Step 2: "Choose your mode" heading. Three vertically-stacked cards: "Personal AI Assistant", "Project Builder", "Both (recommended)". "Both" pre-selected with blue border (`border-primary bg-primary/5`). Clicking other cards moves selection. Step indicator shows "Step 2 of 3". Back button present.
- Step 3: Existing root directory form preserved. "Welcome to [appName]" heading. Back button works (returns to step 2). Get Started submits and closes wizard.
**Why human:** Visual rendering, interaction state, and card selection styling cannot be verified programmatically.

#### 2. Privacy Frame for GPU/Apple Silicon Tiers

**Test:** Run on a machine with a GPU (>= 4GB VRAM) or Apple Silicon. Complete step 1 of onboarding wizard.
**Expected:** Below the hardware stats, "Local AI (recommended for privacy)" heading appears with "Runs entirely on your machine. No accounts. No tracking. Works offline." body text. This text should NOT appear on CPU-only machines.
**Why human:** Hardware tier depends on the runtime machine. The test environment (Linux, no discrete GPU) only exercises the `cpu_only` path.

#### 3. Mode Persistence to Disk

**Test:** Complete the full onboarding wizard with "Personal AI Assistant" selected. After wizard closes, check `~/.paperclip/<instance>/data/nexus-settings.json` (or wherever `resolvePaperclipInstanceRoot()` resolves).
**Expected:** File contains `{ "mode": "personal_ai" }`.
**Why human:** Requires a full running server instance and end-to-end wizard submission.

### Gaps Summary

No automated gaps found. All 11 must-have truths are verified. All 11 artifacts are substantive and wired. All 4 key links are confirmed. All 4 requirement IDs (ONBD-01, ONBD-02, ONBD-03, ONBD-07) are satisfied.

The sole TS error (`AgentConfigForm.tsx detectModel`) is pre-existing and introduced by commit `1583a2d6` prior to phase 30. Phase 30 did not touch `AgentConfigForm.tsx` (confirmed by `git log -- ui/src/components/AgentConfigForm.tsx` showing no phase-30 commits).

3 items are routed to human verification: the visual wizard flow, privacy frame display on capable hardware, and mode persistence end-to-end.

---

_Verified: 2026-04-02T23:58:20Z_
_Verifier: Claude (gsd-verifier)_
