moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
# Warm-Hive Redesign — Build Plan (DRAFT, pending founder confirmation)
> Source design package: `docs/design_handoff_waggle_app/` (README.md, SCREENS.md,
> DESIGN_POV.md, `design-files/styles/waggle.css`, 19 HTML refs + screenshots).
> This plan recreates that concept **inside the existing `apps/web` stack** (React 19 +
> TS + Vite + Tailwind 3 + shadcn/ui + React Router 6) — not by pasting the HTML.
>
> **Status:** proposal. No feature code until the open decisions in §7 are confirmed.
---
## 1. The concept in one paragraph
A warmer **"Hive"** identity that fixes five live-product problems: overloaded nav
(~18 destinations), engineer-first language, flat 1013px density, five semantic colors
firing at once, and buried differentiators. The fixes: a **5-item calm spine + ⌘K
command bar** (progressive disclosure), **plain labels with the technical term as a mono
subtitle**, a **single honey accent** with desaturated status-only semantics, **warm
graphite ↔ warm paper** themes, and **"hero moments"** for memory / overnight work /
coordination. Memory is sold as a trustworthy, *editable, accountable* asset — the
**Memory-Trust layer** (DESIGN_POV #1, already designed) is the keystone.
---
## 2. Current state → target gap (grounded in codebase recon)
| Area | Current reality (verified) | Target | Gap size |
|---|---|---|---|
| **Tailwind** | **3.4.17**, JS config `apps/web/tailwind.config.ts` (CLAUDE.md's "Tailwind 4" is stale; root has the `@tailwindcss/vite` plugin but web uses TW3) | n/a — keep TW3, remap token *values* | none (no migration) |
| **Theme infra** | `data-theme="light"` attribute + `localStorage['waggle-theme']`; boot in `App.tsx`, toggle in `SettingsApp.tsx`, reactivity via MutationObserver in `AppShell.tsx` + `useIsLightTheme.ts`. `next-themes` installed but **unused**. | A real **ThemeProvider** owning `data-theme` + persistence + system pref | medium (consolidate scattered logic; infra already matches design's `[data-theme]` selector) |
| **Tokens** | hive-grays (12) + honey (7) + status (5) + KG colors; cooler palette (`--hive-950:#08090c`, `--honey-500:#e5a000`). shadcn core in HSL. | warm Hive set from `waggle.css` (`--bg:#14110b`, `--honey:#e9a52c`, desaturated semantics, +wash/line/glow, r-sm..r-xl, 4 shadows) | medium (values swap + add missing tokens) |
| **Fonts** | Space Grotesk (display) + DM Sans (body) + JetBrains Mono | **Hanken Grotesk** (display+body) + JetBrains Mono | small |
| **UI lib** | **shadcn/ui fully installed**`components.json`, 57 `ui/` components, `cn()`, CVA, Radix, lucide, framer-motion, sonner, cmdk. (`base-ui` at root but unused.) | same — style shadcn primitives to the tokens | none (design's shadcn assumption holds) |
| **Shell** | `AppShell.tsx` w-52 left nav rendering the **full 5-zone tree** (Work/Intelligence/Extend/Team/System, ~18 destinations) via `dock-tiers.ts` | **5-item spine** (Home, Chat, Memory, Agents & tasks, Library) + workspace switcher + user row; everything else → ⌘K | large (IA collapse) |
| **⌘K** | `CommandCenter.tsx` exists — cmdk, Ctrl/Cmd-K, 6 verb groups, backend search | regroup to **Jump to / Do / Power tools**, plain-name + mono subtitle, first-result auto-select, **Pro "★ Pinned"** group | medium (rework existing) |
| **Routing** | React Router 6.30, ~28 routes under `AppShell` layout, `routeFor(appId, ctx)` | unchanged; spine items map onto existing routes | none |
**Net:** the *plumbing* is in great shape (data-theme strategy, shadcn, cmdk, routing
all align with the design). The real work is (a) a faithful **token + font swap**, (b) a
**ThemeProvider** to replace ad-hoc DOM code, (c) **collapsing the visible nav to 5 + ⌘K**,
then (d) rebuilding screens to the specs.
---
## 3. Token mapping (PR1) — `waggle.css` → `apps/web`
**Strategy:** make the design's **named tokens the source of truth** in `index.css`
(`:root` dark + `:root[data-theme="light"]` light), set to the exact hex from
`waggle.css`, then point the shadcn HSL core tokens and the existing hive/honey scales at
those warm values so the 57 themed components restyle automatically.
### 3.1 Named tokens (verbatim from `waggle.css` §7) — add to `index.css`
- Surfaces: `--bg --bg-2 --surface --surface-2 --surface-3`
- Lines: `--line --line-soft --line-strong`
- Text: `--text --text-2 --text-muted --text-dim`
- Honey: `--honey --honey-bright --honey-deep --honey-wash --honey-line --honey-glow`
- Semantics: `--work --intel --healthy --attention --risk` + each `*-wash`
- Shadows: `--shadow-sm --shadow --shadow-lg --shadow-pop` (warm light variants)
- Radii: `--r-sm:8 --r:12 --r-lg:18 --r-xl:26`; pills/toggles `999px`
- Type: `--sans` (Hanken Grotesk) · `--mono` (JetBrains Mono) · `--serif: var(--sans)`
### 3.2 shadcn HSL core → derive from warm palette (recolor)
Convert warm hex → HSL channels (e.g. `--bg #14110b → --background: 40 29% 6%`):
`--background←--bg` · `--foreground←--text` · `--card/--popover←--surface` ·
`--secondary/--accent/--muted (surface)←--surface-2` · `--muted-foreground←--text-muted` ·
`--border/--input←--line` · `--ring←--honey` · `--primary←--honey` with
`--primary-foreground:#1a1407` · `--destructive←--risk`. Keep the existing
`hive-*`/`honey-*` Tailwind scales but recolor their CSS vars to the warm steps.
### 3.3 Fonts
Swap the Google Fonts `@import` to **Hanken Grotesk (300800) + JetBrains Mono (400600)**;
set `body`/`--font-sans` to Hanken, `h1h6` display to Hanken 600 (`-0.02..-0.03em`),
keep `--font-mono`. (Self-host in the Tauri/Platform pass for offline — follow-up.)
### 3.4 Utilities (port into `waggle-theme.css`, reconcile with existing)
`.hex` clip-path (reconcile with existing `.hex-avatar`) · `.comb` honeycomb data-URI ·
`.dot-live` + `@keyframes breathe` (reconcile with existing `honey-pulse`) ·
`:focus-visible` honey outline · `::selection` honey · warm scrollbar · `.pill` `.kbd`.
### 3.5 PR1 verification
`tsc -p apps/web/tsconfig.app.json` 0 errors · `npm run test` (FE) green · `npm run lint`
clean · visual smoke: dark default + light toggle on Home/Chat/Settings, no contrast
regressions (re-run `light-mode-tokens.test.ts`).
---
## 4. Theme provider (PR1)
New `apps/web/src/providers/ThemeProvider.tsx`: context owning `'dark' | 'light' | 'system'`,
writes `data-theme` + `localStorage['waggle-theme']`, subscribes to
`matchMedia('(prefers-color-scheme)')` when `system`, honors `prefers-reduced-motion` for
entrance gating. Exposes `useTheme()`. **Refactor:** remove the boot snippet in `App.tsx`,
the toggle logic in `SettingsApp.tsx`, and re-back `useIsLightTheme()` with the context
(keep its signature). Recommend a **small custom context** over `next-themes` (Vite SPA,
not Next; keeps the exact `data-theme` contract the design's CSS already targets).
---
## 5. App shell — 5-item spine + ⌘K (PR2)
### 5.1 Sidebar (`ia.html`)
Replace the zone-tree render in `AppShell.tsx:272303` with:
**workspace switcher pill** (hex + name + chevron) → **5 nav items** with honey active
state (left honey bar + `--honey-wash`) → "Everything else" **⌘K tile** → spacer →
**user row** (avatar + name → Settings). Spine → routes:
| Spine item | Route (initial) | Later combined surface |
|---|---|---|
| Home | `/home` | — |
| Chat | active workspace chat `routeFor('chat', ctx)``/workspaces/:id/chat` (fallback `/home`) | — |
| Memory | `/memory` | + per-workspace Memory tab |
| Agents & tasks `[badge]` | `/agents` (badge = pending approvals) | tabs: Agents · Automations · Approvals |
| Library | `/artifacts` | tabs: Artifacts · Files · Skills |
Everything else (waggle-dance, connectors, MCP, marketplace, launcher, room, vault,
mission-control, timeline, usage, team, evolution, benchmark, platform) → **⌘K only**.
**Pro mode** (tier-aware toggle) inserts a "Pinned · power tools" group (Agent swarm,
Connectors, Approvals). Pro-pinned may be deferred to a PR2 follow-up.
### 5.2 ⌘K (`CommandCenter.tsx` rework)
Regroup to **Jump to / Do / Power tools**; each result = icon + **plain name** + **mono
subtitle** (technical term) + optional shortcut; first result auto-selected; **Pro "★
Pinned · Pro"** group prepended in Pro mode. Reuse cmdk + the existing adapter search.
Copy patterns from SCREENS §05 ("Run a team of agents · waggle-dance · swarm", "Connect a
tool · MCP servers · 21 tools", "Launch a coding agent · Claude Code · Cursor · Codex").
### 5.3 PR2 verification
tsc/test/lint green · **live smoke**: every spine item routes; ⌘K opens (Ctrl/Cmd-K),
fuzzy filters, arrow/Enter/Esc nav, routes + closes; all hidden destinations reachable
from ⌘K; active-state highlight via `matchNavRoute`.
---
## 6. Phased roadmap (follows README §12; ship dark first)
| PR | Scope | Key files | Screens |
|---|---|---|---|
| **PR1** | Tokens → TW theme + ThemeProvider + fonts | `index.css`, `tailwind.config.ts`, `waggle-theme.css`, `providers/ThemeProvider.tsx`, `App.tsx`, `SettingsApp.tsx`, `useIsLightTheme.ts` | (foundation) |
| **PR2** | App shell: 5-item sidebar + ⌘K rework | `AppShell.tsx`, `dock-tiers.ts`, `CommandCenter.tsx` | 05 |
| **PR3** | Home (A Editorial) · Chat (B split-canvas) · Workspace (A overview+tabs) | `HomeCockpit`, `ChatWindowInstance`, `WorkspaceChatApp` | 01·02·03 |
| **PR3.5** | **Memory-Trust layer** (DESIGN_POV #1) — confidence/freshness + forget/correct + stale-review + "why did you do that?" trace | `MemoryCenterApp`, memory adapter, `confidence-badge`/`evidence-*` (exist) | 19 |
| **PR4** | Marketplace + **shared install store ("sync")** (grid + agent-pick + inline-in-chat) | `MarketplaceApp`, new install store | 09 |
| **PR5** | Settings (models-first + failover pilot) · Onboarding (6-step + **model gate**) | `SettingsApp`, onboarding wizard | 11·10 |
| **PR6** | Remaining surfaces: Launcher · Storage/Files · Power surfaces · App-surfaces sextet · Evolution · Benchmark · Platform · Habit-on-Home | many | 06·07·08·16·12·17·18·15·04 |
| **PR7** | Auth (Clerk themed) · Billing (Stripe themed) | `auth`, `billing` | 13·14 |
| **PR8** | `apps/www` landing (Next.js) — full content + identity | `apps/www` | Landing |
> **Memory-Trust early (PR3.5):** DESIGN_POV says its primitives "should land early because
> everything else trades on them." Memory is a spine item, so it surfaces in PR2/PR3 anyway —
> wiring trust right after gives the differentiator a real home before the long tail.
---
## 7. Open decisions (need founder confirmation before implementing)
1. **First-PR scope** — split **PR1 (tokens + ThemeProvider + fonts)** then **PR2 (shell +
⌘K)** *(recommended — tokens land first, lower risk per PR)*, or one combined PR?
2. **IA collapse** — confirm reducing the always-visible nav from ~18 → **5-item spine +
⌘K**, Settings via user row, power features ⌘K-only (+ optional Pro-pinned). Anything
that **must** stay always-visible beyond the 5?
3. **Memory-Trust placement** — land **early (PR3.5, right after shell)** *(recommended)*
or after the core screens?
4. **Recommend-and-proceed unless you object:** ThemeProvider = small custom context (not
`next-themes`); fonts via Google Fonts now / self-host in Platform pass; keep Tailwind 3.
5. **Flag for later (blocks Billing/PR7, not now):** DESIGN_POV #4 — **who pays for
inference** (BYO-key vs Waggle-metered). Decide before PR7.
---
## 8. Notes / discrepancies surfaced (honesty log)
- CLAUDE.md §1 lists **Tailwind 4** and **base-ui/react**; `apps/web` actually runs
**Tailwind 3.4.17** and **shadcn/ui + Radix** (base-ui unused in web). No action — just
don't trust those two CLAUDE.md lines for this work.
- `next-themes` is a dependency but unused; PR1 either adopts or removes it.
- All 19 screen HTMLs + per-screen "ship this variation" notes are in
`docs/design_handoff_waggle_app/SCREENS.md` — consult per PR.
## 9. PR1 adversarial-review follow-ups (deferred LOW, tracked)
A 17-agent adversarial review of PR1 confirmed 10 findings; 8 were fixed in-PR
(1 HIGH light-mode honey-button contrast + WCAG ratchet extension, 1 MEDIUM
spine/pinned `/approvals` double-active collision, duplicate Agents/swarm badge,
unbacked ⌘K shortcut hints, platform-aware ⌘K glyph). Two LOW items are
deferred to PR3 with rationale:
- **Chat spine item, no-workspace state** — `routeFor('chat')` falls back to
`/home` when there's no real workspace, so the clicked Chat item isn't
highlighted (Home wins). Semi-intentional: Home **is** the workspace selector
(`routes.ts` §9.7). PR3 can dim Chat or route it to the workspace switcher
when `!hasRealActiveWorkspace`.
- **Sidebar user row `userName={null}`** — renders "Account" + "W" avatar. A real
display name exists in the `/api/home/briefing` contract (`HomeBriefing.userName`);
thread it through when the user-identity surface lands (PR3).

View File

@@ -0,0 +1,407 @@
# PR3 — Build Plan · Warm-Hive Redesign · Screens 01 / 02 / 03
> Rebuilds three screens to the warm-Hive design **inside** the existing `apps/web`
> stack (React 19 + TS + Vite + Tailwind 3 + shadcn/ui + React Router 6). Recreate the
> concept — do **not** paste the HTML. One branch, one PR, commit-per-screen.
>
> Synthesised from the four recon files
> (`docs/redesign-warm-hive/pr3-recon/{home,chat,workspace,primitives}.md`),
> `BUILD-PLAN.md` §6, and `docs/design_handoff_waggle_app/{SCREENS.md §01/02/03, DESIGN_POV.md}`.
> Citations are `file:line` against the branch `feature/warm-hive-redesign`.
---
## 0. Scope + ground rules
### In scope (3 screens, ship-variation locked)
- **Screen 01 — Home / Cockpit → Variation A "Editorial"** (`HomeCockpit.tsx`).
- **Screen 02 — Chat / agent runtime → Variation B "Split work canvas"** (`ChatApp.tsx`
inside the kept-alive `ChatHost` portal subtree).
- **Screen 03 — Workspace → Variation A "Overview + tabs", Memory stays a tab**
(`WorkspaceDesktopApp.tsx` + `WorkspaceRoute.tsx`).
### Out of scope (do NOT build here)
- **PR3.5 — Memory-Trust layer (screen 19, DESIGN_POV #1).** Confidence/freshness on
every memory, Forget/Correct, stale-review, "why did you do that?" trace. Separate
later PR. **PR3 hooks to leave** (cheap, non-fabricating):
- Use the existing `ConfidenceBadge` + `EvidenceChip`/`EvidencePanel`/`DetailDrawer`
primitives (primitives.md §2) for fact rows and activity-stream provenance, so PR3.5
can attach forget/correct affordances to the **same** components rather than re-laying
them out.
- Make every "What Waggle knows" fact row (Workspace) and every memory step (Chat)
**clickable into a `DetailDrawer`** placeholder (or no-op with a `data-memory-id`
attribute) so PR3.5 wires the trace/correct flow without a re-layout.
- Keep the J08 review banner + `home-cockpit-review-cta` event payload intact (it is the
Home entry-point into the unreviewed-memory queue PR3.5 expands).
- **Do not** invent confidence/freshness numbers in PR3 — render provenance/confidence UI
only where real data exists (see §5); leave the affordance, not fake data.
- **PR4+ surfaces** (Marketplace, Settings, Onboarding, Launcher, Power surfaces, etc.) —
untouched. PR3 only restyles the three named screens; the 5-item spine + ⌘K (PR2) and
tokens/ThemeProvider/fonts (PR1) are **already shipped** — reuse, do not re-author.
### Two deferred PR1 LOW items to clear in PR3 (BUILD-PLAN §9)
1. **Chat spine no-workspace dead-click.** `routeFor('chat')` falls back to `/home` when
there is no real active workspace, so the Chat spine item isn't highlighted (Home
wins) and the click feels dead. **Fix in PR3:** when `!hasRealActiveWorkspace`, route
the Chat spine item to the workspace switcher / Home selector and/or dim it. Touch
`Sidebar.tsx` consumer wiring (the spine-item builder), not the screen bodies.
2. **Sidebar user-row `userName={null}`** renders "Account" + "W". A real name exists in
`HomeBriefing.userName` (home.ts:261-270). **Fix in PR3:** thread `briefing.userName`
through to the Sidebar user row once Home loads the briefing (or via the existing
identity surface). This is the "user-identity surface lands (PR3)" note.
### Execution model + process
- **In-place refactor of `apps/web`.** No Lovable, no new app. Restyle/replace the render
bodies of the three existing components; keep route wrappers, data hooks, load() states,
and all test-locked testids/behaviours.
- **One branch (`feature/warm-hive-redesign`), one PR, commit-per-screen.** Suggested
commits: `Phase 0 primitives`, `Phase A Home`, `Phase B Chat`, `Phase C Workspace`,
`LOW fixes (chat no-ws + userName)`, then verification fixups.
- **Dark-first** (BUILD-PLAN §6); light mode inherits via tokens — every new color must
resolve from a `var(--token)` so the WCAG ratchet (`light-mode-tokens.test.ts`) stays
green. Honor `prefers-reduced-motion` on every animated atom.
- **Reuse > rebuild; degrade gracefully on missing data; surgical changes** (CLAUDE.md §3).
---
## 1. Phase 0 — Shared primitives first (build BEFORE the screens)
All warm tokens + utility classes the design references **already exist** (primitives.md
§1: `--bg --surface* --line* --text* --honey* --work --intel --healthy --attention --risk`
+ washes, `--r/-lg/-xl`, shadows, `.hex`, `.comb`, `.dot-live`, Hanken/JetBrains).
**Do not re-add tokens.** Build only the **net-new composite atoms** (primitives.md §3)
that no component provides today, in a **new folder `apps/web/src/components/os/warm/`**.
Keep each < 80 LOC, token-driven, a11y-labeled, `prefers-reduced-motion`-safe.
**Build order** (shared atoms first, then screen-specific composites):
| # | Primitive | File | Minimal prop API | Consumed by |
|---|---|---|---|---|
| 0.1 | `HexAvatar` | `os/warm/HexAvatar.tsx` | `label: string; size?: number; gradient?: boolean; className?``.hex` clip + honey gradient + `#1a1407` initial; extract from `Sidebar.tsx:116` | **Home** (ws cards), **Chat** (context header + bot avatar), **Workspace** (46px header) |
| 0.2 | `SectionLabel` | `os/warm/SectionLabel.tsx` | `children; className?` — 11px mono, uppercase, `.12.14em` tracking, `--text-dim`, trailing hairline rule (ref `Sidebar.tsx:102`) | all 3 screens |
| 0.3 | `DotLive` | `os/warm/DotLive.tsx` | `tone?: 'healthy'\|'attention'\|'risk'\|'work'\|'intel'\|'honey'; className?` — colored dot + `.dot-live` breathe; `prefers-reduced-motion` | **Home** greeting, **Chat** model pill, **Workspace** "agent live" |
| 0.4 | `ProvenanceLine` | `os/warm/ProvenanceLine.tsx` | `source: string; when?: string; onClick?``⬡ source · when`, mono `--intel`; thin wrapper over `EvidenceChip` styled to `--intel` | **Workspace** fact + recent-work rows; **Chat** activity steps |
| 0.5 | `RunChip` | `os/warm/RunChip.tsx` | `label: string; tone?: StatusTone` — status dot + label inline chip | **Home** overnight hero |
| 0.6 | `IconTile` | `os/warm/IconTile.tsx` | `icon: ElementType; tone?: StatusTone; size?: number` — tinted (`*-wash`) rounded icon tile | **Home** "Waggle suggests"; **Workspace** recent-work ext tiles |
| 0.7 | `HexCheckTile` | `os/warm/HexCheckTile.tsx` | `tone?; size?` — small `.hex` tile w/ check glyph | **Workspace** "What Waggle knows" fact rows |
| 0.8 | `StreakChip` | `os/warm/StreakChip.tsx` | `days: number; weekDots?: boolean[]; className?` — 🔥 + "N-day streak", `--honey-wash`/`--honey-line` | **Home** greeting (mock data — §5) |
| 0.9 | `ModelPill` | `os/warm/ModelPill.tsx` | `mode?: string; model: string; onClick?; title?` — "auto · Claude Sonnet" pill + healthy `DotLive` | **Chat** context header |
| 0.10 | `OvernightHero` | `os/warm/OvernightHero.tsx` (Home composite) | `eyebrow; statement: ReactNode; runs: RunChipProps[]``--r-xl` gradient card + honey radial glow; renders gracefully with `runs=[]` | **Home** (composes `RunChip`) |
| 0.11 | `AskBar` | `os/warm/AskBar.tsx` | `placeholder?; onSubmit: (text) => void; cmdkHint?: boolean; onPlus?` — full-width pill, honey `+`, ⌘K hint, honey send | **Home** (Chat composer reuses the send affordance only) |
| 0.12 | `ActivityStream` | `os/warm/ActivityStream.tsx` (Chat composite) | `summary: string; durationMs?; steps: {tone; text: ReactNode; provenance?: {source; when?}}[]; defaultOpen?: boolean` — collapsible `--bg-2` card, violet spark, per-step `DotLive` + `ProvenanceLine` | **Chat** activity card |
| 0.13 | `InlineApprovalCard` | `os/warm/InlineApprovalCard.tsx` | reuse `ApprovalRequest`; `onApprove; onDecline; alwaysAllow?``--honey-wash` bg, attention border, warning icon (inline, NOT a modal); shares risk vocab w/ `risk-display.tsx`/`ApprovalModal` `RISK_LABELS` | **Chat** approval card |
**Reuse as-is (do NOT rebuild — primitives.md §2):** `ConfidenceBadge`, `EvidenceChip`,
`EvidencePanel`, `StatusBadge`, `ApprovalModal` (vocab source), `DetailDrawer`, all shadcn
`ui/` primitives (tabs/card/button/badge/input/textarea/tooltip/popover/scroll-area),
`Sidebar` active-state recipe, `command-catalog.ts` (⌘K vocabulary).
**Phase 0 footprint: ~13 new files (all in `os/warm/`), 0 modified.**
Verify after Phase 0: `tsc -p apps/web/tsconfig.app.json` → 0; new atoms render in isolation
(optional smoke test file). No screen wiring yet.
---
## 2. Phase A — Home (Editorial)
**Primary file:** `apps/web/src/components/os/apps/HomeCockpit.tsx`. **Keep the component,
its route (`HomeRoute.tsx:13`), props, `load()` (`:467-497`), the `connecting` race guard
(`:499-507`), and all five render states.** Restyle/replace **only the normal-render body**
(`HomeCockpit.tsx:580`, `data-testid="home-cockpit"`) and the sub-state shells.
### A.1 Section build order (top → bottom, home.md §4)
1. **Container** — swap `max-w-4xl mx-auto p-6` (`:580`) → centered
`max-w-[920px] mx-auto px-8 pt-[46px] pb-20`. Optional `.comb` honeycomb layer behind.
2. **Greeting** (rework `GreetingHeader` `:136-159`): mono date row (`--honey`, uppercase,
`.1em` tracking) with leading `DotLive tone="healthy"` + right-aligned `StreakChip`;
two-line H1 (Hanken 600, `clamp(34px,5vw,52px)`) with the keyword honey-spanned. **Keep
the human-date rendering** (`formatBriefingDate` semantics, test 6).
3. **J08 review banner** (`:601-625`) — **KEEP intact** (testid `home-cockpit-review-banner`,
cta `home-cockpit-review-cta`, event `{appId:'memory',filter:'unreviewed'}`); may restyle
to a warm attention row. Test-locked (test 2/3).
4. **`OvernightHero`** (new, replaces `OvernightPanel` `:230-288`): eyebrow "While you slept"
+ composed story sentence + ≤4 `RunChip`s. **Must render when overnight is null/empty**
(the test stubs `getHomeOvernight → null`) — degrade to a quiet "Nothing ran overnight"
line, never crash.
5. **"Pick up where you left off"** (restyle `RecentWorkspacesPanel` `:162-227`): change head
copy "You were working on" → **"Pick up where you left off"** via `SectionLabel`; 2-col
grid of warm `.ws-card` with `HexAvatar` (glyph from `name[0]`), title/time/summary,
**"Continue →"**, optional status badge. **Keep `WorkspaceActionsMenu` kebab** (real
G1 CRUD). Returns null at 0 cards.
6. **"Waggle suggests"** (restyle `SuggestedActionsPanel` `:321-343`): head → **"Waggle
suggests"**; pills → stacked `.move` rows (`IconTile` + title + composed sub-line +
sliding arrow). Click → `onContinue(workspaceId, sessionId)`.
7. **"Up next"** (`UpNextPanel` `:291-318`): **KEEP conditional** — testid
`home-cockpit-upnext` present-when-items / absent-when-empty is test-locked (test 4/5).
Restyle only.
8. **`AskBar`** (new, replaces `QuickCapturePanel` `:346-434`): pill + honey "+" + ⌘K hint +
honey send. Wire send → `onContinue`/new-chat; `+` keeps quick-capture (`adapter.quickCapture`)
or opens ⌘K (`CommandCenter`).
9. **Drop:** `ActiveModelsTile` (`:437-448`, never populated) and the segmented QuickCapture panel.
### A.2 Data wiring (home.md §2 — real vs graceful-mock)
- **REAL (wire directly):** `briefing.greeting`, `briefing.userName` (also feeds the LOW
user-row fix §0), `briefing.date` (humanize + add time), `recentWorkspaces[]`
(name/group/summary/`formatRelative(lastActive)`/pendingCount), `suggestedActions[].label`,
`upNext[]`, `overnight.{consolidated,artifactsCreated,automationsCompleted,failures}`,
`needsReviewCount`.
- **COMPOSED client-side (honest, from real fields):** overnight **story sentence** from
counts + failures; run-chip labels for `"N memories consolidated"`/`"N artifacts created"`/
`"N export failed"`; suggestion **sub-lines** from workspace name + relative time. Hex
glyph from `name[0]`.
- **MOCK — no data source anywhere (flag with `// TODO(backend): no data source yet`):**
- 🔥 **streak** value (no `streak` field exists; habit-loop SCREENS §15 unimplemented).
- "You're **ahead** of yesterday" second H1 line (no ahead-vs-yesterday signal).
- "Teardown drafted · 9 competitors" first run chip + the "9 competitors" count (no
per-run label field).
- Workspace-card "agent live" badge (no per-card live flag) → derive or omit, don't fake.
### A.3 Exact copy (SCREENS §01 / home.html)
- Date row e.g. `"Friday · June 14 · 8:42"` (compose from `briefing.date`); streak `"🔥 12-day streak"`.
- H1 line 1 = `briefing.greeting` ("Good morning, Mara."); line 2 = "You're **ahead** of yesterday." (mock).
- Eyebrow "While you slept"; story "Waggle finished the **Q2 competitor teardown**, folded
**14 new memories** into the hive, and ran into **one snag** worth a look." (numbers from real counts).
- Run chips: "Teardown drafted · 9 competitors" (healthy), "14 memories consolidated" (intel),
"2 artifacts created" (work), "1 export failed" (risk).
- Section heads "Pick up where you left off" / "Waggle suggests".
- Ask-bar placeholder "Start something new — \"draft the board update from this week's work\"…".
### A.4 Test contract to keep green — `apps/web/src/test/p2-home-desktop.test.tsx`
1. `home-cockpit` root testid present after load.
2. `home-cockpit-review-banner` text "3 imported memories need your review" + `home-cockpit-review-cta`
click dispatches `waggle:open-app {appId:'memory',filter:'unreviewed'}`.
3. Banner omitted when `needsReviewCount` undefined.
4. `home-cockpit-upnext` absent when `upNext` empty/undefined.
5. `home-cockpit-upnext` contains item label when ≥1 item.
6. Raw ISO must NOT appear; date humanized via `toLocaleDateString`.
- Must render `home-cockpit` with **all-empty arrays** + `getHomeOvernight → null` (no crash).
- Other testids (`home-cockpit-overnight/-suggested/-continue-*`, quickcapture) are **not**
asserted → free to rename/remove.
### A.5 Files touched (Phase A)
- **Modify:** `HomeCockpit.tsx` (normal-render body + sub-states). Optionally co-locate
presentational subcomponents in a `home/` dir (CLAUDE.md "many small files") — at minimum
`OvernightHero`, `AskBar`, `StreakChip` come from `os/warm/`.
- **Touch (LOW fix #2):** Sidebar user-row wiring to pass `briefing.userName`.
- **Verify:** `tsc -p apps/web/tsconfig.app.json` 0; `npm run test --root apps/web`
`p2-home-desktop.test.tsx` green; light ratchet green; live smoke (cold load + empty arrays
+ null overnight render `home-cockpit`).
**Phase A footprint: ~14 files modified (HomeCockpit + optional `home/` subcomponents + Sidebar wiring), 03 created.**
---
## 3. Phase B — Chat (split work-canvas)
**Primary file:** `apps/web/src/components/os/apps/ChatApp.tsx`. The thread, tool cards,
artifact card, approval gate, model picker, persona pill, composer, and SSE/block reduction
(`useChat.ts`) **all already exist and are design-compatible** — most of Phase B is RESTYLE
+ token migration. Two things are net-new: the **right work canvas** and the **collapsible
ActivityStream**.
### B.1 Where the canvas lives (chat.md §1 — critical)
Append `<ChatWorkCanvas>` as a **sibling of ChatApp's chat column** inside ChatApp's existing
flex root — root `<div className="flex h-full relative">` (`ChatApp.tsx:697`), chat column
`<div className="flex flex-col flex-1 min-w-0">` (`:744`). This keeps the canvas **inside the
kept-alive portal subtree** (`ChatHost.tsx`), so its state survives navigation for free.
**Do NOT** add the canvas as a `WorkspaceDesktopApp` pane — that breaks the `chatSlot`/`activeTab`
contract pinned by `p1a-chat-state.test.tsx` and lives outside keep-alive.
### B.2 Build order
1. **Token migration** — Hive-DS classes (`bg-primary`, `text-emerald-400`, `bg-secondary`,
`border-border`) → warm tokens (`--surface`, `--honey`, `--intel`, `--bg-2`, `--line-soft`).
2. **Context header** (restyle `chat-header` `:746`): `HexAvatar` "C" + workspace name + mono
sub "workspace · N memories · N sources" + right-side `ModelPill` (the current model picker
`:931` restyled with a healthy `DotLive` + "auto ·" prefix) + Memory icon button (the
current Memory chip `:815`). Autonomy/storage/team chips not in the design header → fold
into the overflow menu via the existing `chat-header-layout.ts` decision.
3. **Thread restyle** (`:1050-1191`):
- User bubble (`:1108`): `bg-primary rounded-xl``--surface` bubble, asymmetric radius
`4px 14px 14px 14px`, 15.5px/1.55.
- Bot message: `HexAvatar` "W" + meta line "Waggle · {persona} · {model}"; honey `◆` bullets.
4. **`ActivityStream`** (new, chat.md §5 #3): a grouping wrapper in `chat-blocks/`
`BlockRenderer` collapses a consecutive run of `step` (and optionally tool) blocks into one
collapsible card: violet spark + "Worked across memory, web & files · N steps · Ns" header
+ chevron. Derive step count + duration from the grouped blocks. **Default-open on the
active turn** (track `isStreaming`/active turn), collapsed on prior turns. Per-step
`DotLive` + `ProvenanceLine` **only when source data exists** — do NOT fabricate
`⬡ mem://hive` (SSE `step` carries no source field today; flag to backend in §5).
5. **`InlineApprovalCard`** (restyle `ApprovalGate` `:223`): `--honey-wash` bg + attention
border + warning icon; copy "Approve before I leave your machine" + mono external target +
"Approve & export"/"Not now". **Keep `RiskBadge` + the Always-allow gating logic.**
6. **`ChatWorkCanvas`** (new `<aside>`): `--bg-2`, `--line-soft` left border, `width:42%`,
`transition .25s`. Head: title (artifact basename) + healthy mono "● live draft" + "Open in
Artifacts" icon button. Body = `.doc` rendered via the **existing markdown path** (reuse
`TextBlock`'s renderer) from the **latest completed `write_file`/`edit_file` block's
`input.content`/`result`** (reuse `ArtifactBlock.isArtifactBlock` `:21` + path logic).
Blinking honey type-cursor (`.hex-cursor`) is **cosmetic, shown while `isStreaming`**. Thread
`max-width` drops 760→~620px when open. `@media (max-width:820px)` → hide canvas (chat full width).
Open/close state lives in ChatApp (inside keep-alive).
7. **Composer restyle** (`:1194`): `:focus-within``--honey-line` + `--honey-glow`; placeholder
"Reply, or ask Waggle to take the next step…"; chips row Attach / "Persona: {name}" / Tools;
mono hint "⏎ send · ⌘K commands"; honey ↑ send (40px). Keep the slash menu + send wiring.
### B.3 Data wiring (chat.md §2)
- **REAL (no hook change):** the entire `useChat` stream/`blocks[]` reduction; tool cards;
artifact card + Open-in-Files; feedback; slash menu; pins; persona; autonomy; keep-alive.
- **DERIVED:** canvas doc body from the latest file-write block; ActivityStream summary
(count/duration) from grouped blocks; ModelPill "auto" when model is default/unset.
- **MISSING (don't fabricate):** structured per-step `source`/provenance (no SSE field) →
render the provenance pill only when present; canvas "live document body" stream (today only
`input.path` + opaque `result`) → PR3 shows the **last completed artifact's content**, "live
drafting" cursor is cosmetic.
### B.4 Test contract to keep green
- **`chat-artifact-block.test.tsx`** — keep `data-testid="chat-artifact-block"` +
`chat-artifact-open`, Created/Updated copy, and the `isArtifactBlock` routing predicate. **The
canvas is ADDITIVE — the inline artifact card stays.**
- **`p1a-chat-state.test.tsx`** — do NOT change `WorkspaceDesktopApp`'s `chatSlot` / `activeTab` /
`onTabChange` contract (test ids `ws-tab-chat`, `ws-tab-tasks`, `ws-tasks-tab`, `chat-slot-stub`,
`ws-chat-tab-open`-absent-when-slot). The canvas lives inside the slot subtree, not as a new
desktop prop. Persona/autonomy persistence (`waggle-chat-state-v1`) unchanged.
- **`chat-blocks/TextBlock.test.tsx`**, **`context-rail-fetch.test.ts`** (the `onContextRail`
double-click path `ChatApp.tsx:1095`) — keep behavior; grep before editing.
### B.5 Files touched (Phase B)
- **Modify:** `ChatApp.tsx` (header/thread/composer restyle + canvas sibling + open state);
`chat-blocks/BlockRenderer.tsx` (group steps into `ActivityStream`); `chat-blocks/StepBlock.tsx`
(feed into ActivityStream); `ChatWindowInstance.tsx` (only if canvas needs a model/"auto" prop —
minimal).
- **Create:** `os/warm/ChatWorkCanvas.tsx` (or `chat-blocks/`), `os/warm/ActivityStream.tsx`,
`os/warm/InlineApprovalCard.tsx`, `os/warm/ModelPill.tsx` (from Phase 0), a small canvas-content
selector helper.
- **Verify:** `tsc -p apps/web/tsconfig.app.json` 0; FE vitest green incl. `chat-artifact-block.test.tsx`
+ `p1a-chat-state.test.tsx` + `chat-blocks/TextBlock.test.tsx`; live smoke (send a message →
activity card default-open → file-write opens canvas → approval card renders → navigate away and
back, canvas state survives).
**Phase B footprint: ~4 files modified, ~45 files created.**
---
## 4. Phase C — Workspace (Overview + tabs)
**Primary files:** `apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx` (1020 LOC) +
`apps/web/src/routes/WorkspaceRoute.tsx`. Rebuild the **shell header + tab bar + Overview**;
the other 5 tabs embed/deep-link the **real existing screens** (workspace.md §6).
### C.1 Build order
1. **Tab set 8 → 6** (workspace.md G1): drop Research/Tasks/Timeline/Settings from the bar;
final set **Overview · Chat · Memory · Artifacts · Files · Team**. Update `WorkspaceTabId`
union + `TABS` (`WorkspaceDesktopApp.tsx:58-77`) and `WS_TABS` (`WorkspaceRoute.tsx:20-22`).
- **Do NOT delete `<TasksTab>` the component** — relocate Tasks into the Overview "Up next"
card; keep it reachable. Unknown `:tab?` already falls back to Overview
(`WorkspaceRoute.tsx:43-45`), so stale `/workspaces/:id/tasks` links degrade gracefully.
- **Files/Team** are NEW tabs → embed the existing Files surface (`getWorkspaceFiles`,
already loaded) and Team (global roster today — documented TODO).
2. **Header** (restyle `:759-811`): breadcrumb `Home {name}` (mono, `--text-dim`); 46px
`HexAvatar`; H1 (Hanken 650, 28px); meta row `DotLive`+"1 agent live · N memories · N sources
· updated Xago"; right actions = **Memory** (ghost) + **Continue →** (honey). **Keep
`WorkspaceActionsMenu` kebab** (real CRUD, not in mock).
3. **Tab bar** (restyle `:814-839`): honey 2px bottom-border on active (honey already comes via
`--primary`); add per-tab mono count `.cnt` span. Keep `ws-tab-bar`, `ws-tab-<id>` testids.
4. **Overview re-layout** (workspace.md G7): replace the 3-col widget grid (`OverviewTab`
`:518-545`) with a **2-col grid `1.7fr / 1fr`, gap 22px**:
- **Left col:** summary card (honey-bold keywords, from `ctx.summary`); **"What Waggle
knows"** (`SectionLabel` + `HexCheckTile` fact rows + `ProvenanceLine`); **"Recent work"**
(`IconTile` ext rows + name + sub + mono time + hover honey border).
- **Right col (folds the old `w-72` aside in — G13):** **Status card** (agent/model/memories+delta/
needs-review); **Up next** (derived from pending/blocked/nextActions/schedules); **Team card**
(avatar rows + roles, global roster).
5. **Memory tab** stays `<MemoryCenterTab mind="workspace">` (`:937-941`) — **already correct, keep**.
### C.2 Data wiring (workspace.md §2)
- **REAL:** `ctx.summary`, `ctx.recentMemories`/`recentDecisions` (content + date), `ctx.stats`
(memoryCount/sessionCount/fileCount → tab counts), `getWorkspaceFiles` (recent-work names + times),
`useRoomState` live ("1 agent live"), `ctx.workspace.model`, `getWorkspaceActivity`,
`getTeamMembers` (global), `state.{pending,blocked,nextActions}` + cron schedules (Up next).
`relativeTime` (`:135-147`) + `initialsOf` (`:128-133`) helpers exist.
- **DERIVED:** "updated Xago" from `ctx.lastActive`; ext-tile from filename; tab counts from stats.
- **MOCK / data-gap (render only where real; flag, don't fabricate):**
- **Per-fact / per-artifact provenance `⬡ source · when`** — frames HAVE a `source` column
server-side (`workspaces.ts:291` writes, SELECTs around `:413`) but it is **not projected
into `recentMemories`/`recentDecisions`** today. PR3 renders `ProvenanceLine` **with `when`
(real date) and source only if projected**; otherwise show date-only and leave a
`// TODO(backend): project frame.source` note. (This is the keystone PR3.5 hook — §0.)
- "9 sources" (no `sourceCount`), "+6 today" delta, per-workspace "N to review", a distinct
**artifacts** entity vs files, agent NAME on the live row → mock/omit, flagged.
### C.3 Exact copy (SCREENS §03 / workspace.html) — sample/derive
Summary, fact rows ("Mem0 is cloud-only…", prov `web · mem0.ai · 2h ago`), recent-work
(`teardown.md` "Q2 competitive teardown · 9 competitors · 2h ago"), Status
(`● Research-synth · live` / `auto · Claude Sonnet` / `142 +6 today` / `3 memories`), Up next
(`Board brief from teardown` draft / `Export table → Salesforce` awaiting you / `Weekly digest` 17:00),
Team (`Mara K. · Owner`, `Research-synth · Agent · live`). Use **real** fields where present;
sample strings are mock-until-backed.
### C.4 Test contract to keep green
- **`p1a-workspace-route.test.tsx`** — route↔shell contract: `selectWorkspace(routedId)` on
deep-link / Back-Forward, skip-when-active, never sync `local-default`, `path="workspaces/:workspaceId/:tab?"`.
The `:tab?` param + the `selectWorkspace` effect + the `local-default` guard must survive the rewrite.
- **`lib/workspace-briefing-state.test.ts`** — briefing-collapsed localStorage helpers (key prefix
`waggle:workspace-briefing-collapsed:`); only relevant if briefing-collapse is carried into the new
Overview; helpers reusable as-is.
- **Implicit testid contract (Playwright/smoke):** keep `ws-desktop-root`, `ws-tab-bar`, `ws-tab-<id>`,
`ws-tab-panel`, `ws-overview-grid`, `ws-info-panel`, `ws-status-pill`, `ws-members-stack`,
`ws-agents-running`, `ws-desktop-{loading,permission-denied,notfound,offline,retry}`, `ws-memory-tab`.
Dropping Research/Tasks/Timeline/Settings removes `ws-tab-{research,tasks,timeline,settings}` — none
appear in the two unit tests, but **grep untracked Playwright smokes before deleting**.
- **No test pins the 8-tab set, widget-grid, or right-panel** → the re-layout + tab reduction are free.
### C.5 Files touched (Phase C)
- **Modify:** `WorkspaceDesktopApp.tsx` (header + tab bar + Overview 2-col + tab set, fold aside in),
`WorkspaceRoute.tsx` (`WS_TABS` 6-tab set + union). Optionally `os/apps/workspace/` subcomponents.
- **Create:** `os/warm/HexCheckTile.tsx` + `ProvenanceLine.tsx` (Phase 0); `FactRow`, `ArtRow`,
`StatusCard`, `UpNextCard`, `TeamCard` (co-located `workspace/` or inline if small).
- **Verify:** `tsc -p apps/web/tsconfig.app.json` 0; FE vitest green incl. `p1a-workspace-route.test.tsx`
+ `workspace-briefing-state.test.ts`; live smoke (deep-link a tab, Back/Forward, Overview renders with
real ctx, tabs route to real screens, empty-workspace state still renders).
**Phase C footprint: ~2 files modified, ~57 files created.**
---
## 5. Risks + open questions (need founder/data decision OR graceful degradation)
| Topic | Decision / risk | PR3 default (degrade gracefully) |
|---|---|---|
| **Overnight story = mocked vs real** | Server gives raw counts, no narrative or per-run labels (home.md §2). The hero sentence + "9 competitors" chip are composed/mock. | Compose the sentence from real counts/failures; flag the "9 competitors" chip `// TODO(backend)`. When overnight is null/empty → quiet "Nothing ran overnight" line, still render `home-cockpit`. |
| **Streak source** | No `streak` field anywhere (home.md §2; SCREENS §15 unimplemented). | Render `StreakChip` with a **constant mock value + explicit TODO**, or hide behind a flag until a backend field lands. Do not fake daily logic. |
| **"ahead of yesterday"** | No ahead-vs-yesterday signal. | Render as static copy with TODO, or drop the second H1 line if founder prefers no mock. |
| **Chat canvas content stream** | No live-document stream — only file-write `input.content`/opaque `result` (chat.md §2/§5 #2). | Canvas shows the **last completed artifact's content**; "live draft" cursor is cosmetic during `isStreaming`. Flag the need for a doc-body stream channel to backend. |
| **Provenance `⬡ source` (Chat steps + Workspace facts)** | Frame `source` exists server-side but is **not projected** into `recentMemories`/`recentDecisions`/steps (workspace.md §2; chat.md §2). This is the core trust pattern (README §6) **and** the PR3.5 keystone. | Render `ProvenanceLine` with **real `when` (date)** always; show source **only when projected** — otherwise date-only + `// TODO(backend): project frame.source`. **A tiny server change to project `source` would unlock this for both screens — recommend founder greenlight a 1-field projection in PR3 or PR3.5.** |
| **Workspace data gaps** | "9 sources", "+6 today", per-workspace "N to review", distinct artifacts entity, agent NAME on live row — all absent. | Omit or mock-with-TODO; never block render. |
| **Team roster is global, not per-workspace** | `getTeamMembers()` returns global roster (documented TODO `WorkspaceDesktopApp.tsx:660-662`). | Render global roster as today; note the scope gap. |
| **`continueSessionId` omitted server-side** | "Continue" lands at chat root, not a session (home.md §2). | Continue → chat root; acceptable, pre-existing. |
| **LOW fix — Chat no-workspace** | Needs `hasRealActiveWorkspace` signal in the spine builder. | Route Chat → switcher/Home when no real workspace; dim the item. Confirm the selector source. |
---
## 6. Verification gate (run per phase + final)
**Type + tests (cite exact targets):**
- `npx tsc -p apps/web/tsconfig.app.json`**0 errors** (this is the FE typecheck; the
`npm run -w apps/web typecheck` script is a silent no-op — solution tsconfig).
- FE vitest from `apps/web` root, **green**, specifically:
- Home: `apps/web/src/test/p2-home-desktop.test.tsx`
- Chat: `apps/web/src/test/chat-artifact-block.test.tsx`, `apps/web/src/test/p1a-chat-state.test.tsx`,
`apps/web/src/components/os/apps/chat-blocks/TextBlock.test.tsx`, `apps/web/src/test/context-rail-fetch.test.ts`
- Workspace: `apps/web/src/test/p1a-workspace-route.test.tsx`, `apps/web/src/lib/workspace-briefing-state.test.ts`
- Theme ratchet (all screens): `apps/web/src/test/light-mode-tokens.test.ts`
- `npx eslint .` run from **`apps/web`** cwd → clean.
**Live smoke checklist (per screen, dark + light toggle):**
- **Home:** cold-load renders `home-cockpit`; greeting + streak + hero render; **empty arrays +
null overnight still render** (no crash); J08 banner CTA fires `waggle:open-app`; ask-bar sends;
user-row shows `userName`.
- **Chat:** send a message → ActivityStream default-open on active turn, collapses on prior; a
file-write opens the work canvas; canvas hides < 820px; approval card renders honey-wash with
working Allow/Deny; **navigate away and back — canvas + chat state survive** (keep-alive); inline
artifact card + Open-in-Files still work.
- **Workspace:** deep-link a tab + Back/Forward (route↔shell intact); Overview 2-col renders with
real ctx; fact rows show provenance `when`; tabs route to the real Chat/Memory/Artifacts/Files/Team
screens; empty-workspace + error states still render.
**Definition of done:** tsc 0, all cited FE tests green, eslint clean, live smoke passes on all
three screens in both themes, the two PR1 LOW items cleared, no fabricated data shipped (mocks
carry `// TODO(backend)`), PR3.5 hooks left (clickable provenance/fact rows, intact J08 banner).

View File

@@ -0,0 +1,349 @@
# Warm-Hive PR3.5 — Memory-Trust Build Plan
> Synthesized from the 5 recon files in `docs/redesign-warm-hive/pr35-recon/` (0105),
> all verified on branch `feature/warm-hive-pr3` @ `dac7b696`, 2026-06-16.
> This plan has two halves: **Part 1** = the `frame.source` projection (the keystone
> enabler), **Part 2** = the screen-19 Memory-Trust UI, built in phases on top of it.
>
> Repo root: `D:/Projects/waggle-os`. Substrate: `packages/hive-mind-core/src/mind/`.
> Server: `packages/server/src/local/routes/`. UI: `apps/web/src/`.
>
> **No-fabrication contract (inherited from PR3):** confidence, freshness, and trace-reason
> are the three places this PR could silently invent data. The plan gates each one off
> (PR3 streak pattern) wherever the substrate does not hold a real value. See §3 + §5.
---
## Part 1 — `frame.source` projection (the keystone enabler)
The ⬡ provenance pill on Chat + Workspace is blocked by one missing column in two read
paths. Recon 01 maps the FE-facing path (the `/context` route → `WorkspaceBriefing`); recon
02 maps the SSE step path. The DDL column already exists and is NOT NULL DEFAULT — **no
migration, no DDL change, no backfill.** This is a pure read-side projection.
### 1.1 The column (no change — context only)
`packages/hive-mind-core/src/mind/schema.ts:56-57`
`source TEXT NOT NULL DEFAULT 'user_stated' CHECK (source IN ('user_stated','tool_verified','agent_inferred','import','system'))`.
Persisted rows only ever hold these **5** values (the TS `FrameSource` union at
`frames.ts:25` is wider — `personal|workspace|team_sync` are search-time mind labels that
fail the CHECK and never persist). The FE owns the label/icon map for the 5 values; the
route returns the raw string.
### 1.2 Workspace Briefing path — REQUIRED (the FE pill keystone)
**File A — `packages/server/src/local/routes/workspaces.ts`** (`GET /api/workspaces/:id/context`, handler @ `:364`)
Ordered edits:
1. **`:376`** — widen the `let recentMemories` local from
`Array<{ content: string; importance: string; date: string }>`
`Array<{ content: string; importance: string; source: string; date: string }>`.
2. **`:377`** — widen the `let recentDecisions` local from
`Array<{ content: string; date: string }>`
`Array<{ content: string; source: string; date: string }>`.
*(Skipping 1+2 makes the server `tsc` fail against the narrower locals — and `npm run build` does NOT typecheck the sidecar, so this only surfaces under the §1.4 server tsc.)*
3. **`:393-400`** — `recentMemories` SELECT: add `source` to the column list and to the
`as Array<{...}>` cast → `SELECT content, importance, source, created_at FROM memory_frames …`.
4. **`:402-406`** — `recentMemories` `.map()` projection: add `source: f.source,`.
5. **`:409-417`** — `recentDecisions` SELECT: add `source` to the column list and cast →
`SELECT content, source, created_at FROM memory_frames …`.
6. **`:419-429`** — `recentDecisions` `.map()` returned object: add `source: f.source,`.
(Exact before/after blocks are in recon 01 §2a/§2b — copy them verbatim.) Purely additive;
no existing consumer breaks.
**File B — `apps/web/src/lib/types.ts`** (interface `WorkspaceContext`, `:248-249`)
7. Widen both inline item shapes with an **optional** `source?`:
- `recentDecisions?: Array<{ content: string; source?: string; date: string }>;`
- `recentMemories?: Array<{ content: string; importance: string; source?: string; date: string }>;`
- Keep `source` optional so pre-PR3.5 sidecars still typecheck and the FE degrades to
"no pill" when absent.
**Consumer (no type change, render only) — `apps/web/src/components/os/WorkspaceBriefing.tsx`**
8. `recentMemories` rendered at `:186-197` (importance badge `:189-193`) and `recentDecisions`
at `:164-178`. Swap the bare mono date `<div>` for
`<ProvenanceLine source={m.source} when={m.date} onClick={…} />` **only when `m.source` is
present**; otherwise keep PR3's bare date `<div>` (the date-only fallback — never a
fabricated source). This is the same affordance PR3 already left hooked.
**DO NOT TOUCH — the parallel system-prompt builder.** `workspace-context.ts` /
`workspace-state.ts` (`buildWorkspaceState``WorkspaceNowBlock`) feeds the **system
prompt**, not the FE pill, and its `StateItem.source` is a *different* axis
(`memory|session|awareness`). Adding `frame.source` there is out of scope and would collide
on the `source` name (recon 01 §4). *(Note: recon 05 §7 names `workspace-context.ts:283-284`
as "the" projection gap — that is the system-prompt path; the FE pill keystone is the
`workspaces.ts` `/context` route in recon 01. They are different routes; for PR3.5 the FE
pill, do File A above, not `workspace-context.ts`.)*
### 1.3 SSE step path — DECISION: **project on `auto_recall` only, paired with the recallMemory widen** (recon 02)
Recon 02 verdict: **NEEDS-WIRING, not trivially projectable.** A streamed `step` carries
`{ content: string }` only; the agent loop exposes `onToolUse/onToolResult(name,input,result)`
with no frame/source; the real `source` is destroyed when `recallMemory()` flattens frames to
`string[]` text (`orchestrator.ts:453-457`).
**Project-or-defer decision:**
- **Blanket "source on every step" is WRONG by design** — generic steps (bash, drafting,
budget) have no provenance; stamping them is fabrication (PR3's refused move).
- **Only the memory-recall step (`auto_recall`) has genuine provenance.** Wiring it is
in-scope for PR3.5 **iff paired with the `recallMemory` return-shape widen**. If we want to
stay server-light this arc, **DEFER** the SSE step pill and keep PR3's "render the affordance,
never fabricated provenance" stance — the Chat ActivityStream already gates on
`s.provenance` presence, so nothing breaks.
**Recommended for PR3.5: do the `auto_recall` slice** (it is the honest, load-bearing one and
the same "frame.source 1-field projection" the S2 handoff named). The 5 edits / 4 files:
1. **`packages/agent/src/orchestrator.ts:453-457`** (load-bearing) — widen `recallMemory()`
return to carry per-snippet provenance, e.g. add
`recalledFrames?: Array<{ text: string; source: FrameSource; sourceUrl?: string; when?: string }>`
alongside the existing `recalled: string[]`. Frame objects with `.source` are already in
hand inside `recallMemory` — this is "stop flattening it", not a new query.
2. **`packages/server/src/local/routes/chat.ts:780-781`** — emit `source`/`provenance` on the
`auto_recall` step or its `tool_result` (`{ content, provenance?: { source, when } }` is
additive, backward-compatible).
3. **`apps/web/src/lib/types.ts:474-479`** — add optional `provenance?: { source: string; when?: string }`
to `StepContentBlock` (and tolerate it in `StreamEvent.data` handling, `:616-619`).
4. **`apps/web/src/hooks/useChat.ts:152`** — carry `data.provenance` onto the pushed step block.
5. **`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:30-33`** — project
`s.provenance` into `ActivityStep` (the `ActivityStream`/`ProvenanceLine` path already
gates on presence; remove only the part of the omitted-provenance comment that no longer
holds). Leave all non-memory steps provenance-less by design.
### 1.4 Verification (Part 1)
```bash
# server route typecheck — REQUIRED; npm run build skips the sidecar (runs via tsx transpile-only)
npx tsc --noEmit --project packages/server/tsconfig.json
# agent (only if §1.3 done — recallMemory widen)
npx tsc --noEmit --project packages/agent/tsconfig.json
# FE typecheck — use the app tsconfig directly (the workspace alias is a silent no-op)
npx tsc --noEmit -p apps/web/tsconfig.app.json
npm run test -- --run # FE unit + touched server suites
npm run lint
```
Live check: open a workspace with seeded memories → Chat + Workspace Overview render the ⬡
pill on rows that have a `source`; rows without a source show the bare date (no fabricated
pill). Console: 0 errors.
---
## Part 2 — Memory-Trust (screen 19), phased
Target screen: `docs/design_handoff_waggle_app/design-files/screens/memory-trust.html`
(authoritative mock, 1:1 token match to PR3's warm tokens per recon 04 §8 — no remapping).
Two view modes behind a segmented control: **A "Manage memory"** (default) and **B "Why did
you do that?"**. The standalone host is `MemoryCenterApp.tsx` (screen 19 extends/replaces it);
the reusable core `memory/MemoryCenterTab.tsx` is shared by the Workspace Memory tab — change
once, both update (recon 03).
> **Reuse, don't rebuild:** `ConfidenceBadge`, `EvidenceChip`, `DetailDrawer`, `EvidencePanel`
> already exist in `components/ui/` (commit `afe96355`, pre-PR3) and ship today via Memory
> Center. The new warm atoms are only the 4 listed in §4.
### Phase 0 — Primitives (new warm atoms + token decision)
**Files to create** (`apps/web/src/components/os/warm/`, export from `warm/index.ts:7-20`):
- `ConfidenceRing.tsx` — the §5a 38px ring (see §4).
- `ConfidenceBadge` decision: the existing `ui/confidence-badge.tsx` uses the Hive DS
`--sem-*` family; the warm screen wants `--healthy/--attention/--risk`. **Token-family
decision (founder call, see §5):** align new Memory-Trust confidence atoms to the **warm
`--sem-*`→band** mapping the screen 19 mock uses (green ≥85 / honey ≥60 / terracotta <60),
OR reuse `ui/ConfidenceBadge` if its `--sem-*` bands render acceptably. Default
recommendation: build `ConfidenceRing` against warm tokens (matches the mock exactly),
reuse `ui/ConfidenceBadge` where a pill (not a ring) is wanted.
- `EvidenceChip` for the trace: reuse `ui/evidence-chip.tsx` (recon 03). Only build a thin
`TraceEvidenceChip` wrapper if the §6b `.ev` chip needs the multi-segment
`confidence X% · source · when · still fresh` format.
- `DetailDrawer`: reuse `ui/detail-drawer.tsx` (recon 03) for the memory-detail open.
**Files to modify:** `ProvenanceLine.tsx` — extend to optionally render the §5b 3-segment
form `⬡ <id> · source: <src> · ● <freshness>` (add a `source:`-prefix variant + a `●`
freshness segment colored healthy/attention). Today it renders `⬡ source · when` only.
**Tokens/primitives reused:** all `--honey/--healthy/--attention/--risk/--intel` + washes +
`--r-lg 18px`/`--r-xl 26px` + JetBrains Mono/Hanken already defined in both themes
(`index.css:162-168` dark / `:298-304` light). `HexAvatar`, `SectionLabel`, `DotLive`,
`RunChip`/chip patterns already exist.
**Data:** none (pure presentational atoms).
### Phase A — Header / segmented control + editorial hero + stat bar
**Files to create/modify:**
- New `MemoryTrustApp.tsx` (or extend `MemoryCenterApp.tsx`) hosting the §1 sticky `.controls`
bar: eyebrow `Memory Trust · view`, 2-button segmented switch (`Manage memory` default-on
honey-fill `#1a1407` ink | `Why did you do that?`), swapping `.vlabel`, theme toggle.
- §2 editorial hero per view (honey eyebrow + 28px/650 H1 with honey non-italic `<em>` accent
+ 64ch body with bold `--text-2` spans). Verbatim copy in recon 04 §2.
- §3 stat bar (View A): 4 cards `repeat(4,1fr)`→2-col @820px.
**Primitives/tokens:** `SectionLabel` for eyebrows; honey-fill segment = `.seg button.on`
treatment (`background:var(--honey); color:#1a1407`); warn cards use the
`--attention`/`color-mix` border per recon 04 §3.
**Data — REAL vs route-needed:**
- Stat #1 "Memories in this hive" (`142`) — **REAL**, from `GET /api/memory/stats` (counts
only, mind-isolation honored).
- Stat #2 "High confidence & fresh" (`128`) — **DERIVABLE/PARTIAL**: confidence is sparse
(harvest-only), freshness is derivable from `created_at`. **If confidence is absent for the
mind, gate this card off** (show "—" or hide), do not invent a count. See §3.
- Stat #3 "Stale · worth a review" (`9`) — **DERIVABLE** from `created_at` via
`computeTemporalScore` (FE compute or thin route).
- Stat #4 "Awaiting your confirm" (`3`) — **needs route**: `GET /api/memory?status=unreviewed`
(filter already works, `memory-center.ts:198`) — but nothing *writes* `unreviewed` today, so
the count may legitimately be 0. Wire to the real query; do not seed a fake number.
### Phase B — Search + filters (View A toolbar)
**Files to modify:** the §4 `.toolbar` inside `MemoryTrustApp` — search input (placeholder
`Search what Waggle knows… or ask it to forget something`) + 4 filter chips (`All` on /
`Stale` / `Needs confirm` / `Forgotten`).
**Primitives/tokens:** active chip = `--honey-wash` fill + `--honey-line` border (recon 04 §4);
reuse warm chip pattern.
**Data — REAL vs route-needed:**
- Search → `GET /api/memory?mind&q&kind&status&minConfidence&limit` (`memory-center.ts:167`) —
**REAL**.
- `Stale` filter → **DERIVABLE** client-side over returned `created_at`.
- `Needs confirm` filter → `GET /api/memory?status=unreviewed`**REAL query** (may return 0).
- `Forgotten` filter → **no tombstone exists** (forget is a hard delete, `frames.ts:321`).
**Gate this chip off** or relabel — there is no "forgotten" list to show. See §3.
### Phase C — Memory rows (confidence ring + provenance + forget/correct/confirm/stale)
**Files to create/modify:** the §5 `.mem` row component (3-col `[conf ring] [body] [actions]`),
rendered into `.mems` grid. Reuse the `memory/MemoryCenterTab.tsx` + `MemoryCard.tsx` pattern
(already consumes `DetailDrawer`/`ConfidenceBadge`/`EvidencePanel`); add the warm row treatment.
**Primitives/tokens:** new `ConfidenceRing` (§4); extended `ProvenanceLine` (3-segment §5b);
corrected banner = `--healthy-wash`; stale banner = `--honey-wash`+`--honey-line`; disputed =
`--risk` border + strikethrough; danger-forget hover = `--risk` (recon 04 §5).
**Data — REAL vs route-needed:**
- Row content / id / source → **REAL** (from `/api/memory` `Memory` shape).
- **Forget** button → `DELETE /api/memory/:id?mind=…`**REAL, no new backend** (hard delete +
audit event). Row animates out then removed.
- **Correct / edit** button → `PATCH /api/memory/:id` (content + reclassify) — **REAL, no new
backend**.
- **Confidence ring** → `Memory.confidence`, **only present on harvested frames**. **Hide the
ring (or show a neutral "—" state) when `confidence` is undefined** — never default a number.
See §3.
- **Freshness `●`** → **DERIVABLE** from `created_at` (`computeTemporalScore`, 7-day boost /
30-day half-life). Present as honest age ("aging — last seen 6w ago"), never a stored %.
- **Stale banner / "Still true?"** → derivable trigger; **Confirm** action → **MUST-BUILD
route** `POST /api/memory/:id/confirm` (set `metadata.status='active'`, mirroring
`/archive` at `memory-center.ts:355`). Cheap; the `MemoryStatus` rails exist.
### Phase D — "Why did you do that?" trace (View B)
**Files to create/modify:** the §6 `.trace` card — `HexAvatar` header (`trace #a1f9`), 4-node
connector chain (Goal→Recalled 3→Cross-checked→Acted; dots intel/honey/honey/healthy; evidence
chip `confidence 94% · source: chat · Tue · still fresh`), 3-button footer (Looks right / That
memory is wrong → correct it / Forget #M-204 & redo). Shared trust-principle footnote + green
slide-up toast on both views.
**Primitives/tokens:** `HexAvatar`, `DotLive`, reused `EvidenceChip`; connector line per §6b;
footer go-button honey-fill / ghost / danger per §6c.
**Data — REAL vs route-needed (the honest hard part):**
- Trace store is **rich and populated for chat** (`execution_traces` + `TraceRecorder` per
turn, `chat.ts:1277-1424`): reasoning[], toolCalls[], outcome, cost, tokens.
- **GAP 1 — chat traces aren't addressable by the agent-traces route.** `GET /api/agents/:id/traces`
(`agents.ts:469`) filters by the `agent:{id}` tag, but chat `start()` does NOT pass
`tags:['agent:…']` (`chat.ts:1279-1285`). Fix = either add the `agent:` tag at chat
`start()`, OR add a thin `GET /api/sessions/:id/traces` reading
`traceStore.queryParsed({ sessionId })` (store already supports it, `execution-traces.ts:328`).
- **GAP 2 — no frame↔trace backlink.** Nothing links a `memory_frame` to the
`execution_trace` that produced it (no `trace_id`). So **per-frame "why is THIS memory here?"
is MUST-BUILD** (add `trace_id` to frame metadata at write time + a `GET /api/memory/:id/trace`
resolver). For PR3.5 the **honest, cheap win is a session/turn-scoped trace view** (the real
reasoning + tool calls), NOT a per-frame reason. **Do NOT synthesize a "reason" string for a
frame with no linked trace.** If neither route lands this arc, View B renders against a real
recent session trace or is gated behind "no trace yet" empty state — never fabricated.
### Phase verification (each phase)
Run the §1.4 gate after each phase; live-smoke the screen in dark + light with 0 console
errors before moving on (matches PR3's shipping bar).
---
## 3. Honest data gaps (recon 05)
| Feature | REAL / DERIVABLE / MUST-BUILD | Plan |
|---|---|---|
| **source** (provenance class) | REAL (`memory_frames.source`) | Project per Part 1; FE owns the 5-value label map. |
| **forget** (delete) | REAL (`DELETE /api/memory/:id`, hard delete + audit) | Wire button straight to it. No backend. |
| **correct** (edit) | REAL (`PATCH /api/memory/:id`) | Wire edit/correct to it. No backend. |
| **freshness / staleness** | DERIVABLE (`computeTemporalScore` over `created_at`, 7d boost/30d half-life) | FE compute or thin helper. **Present as honest age, never a stored "%".** |
| **confidence** | PARTIAL (metadata blob; **harvest-only**, `undefined` for curated/agent/quick-capture frames) | **Show ONLY when present; hide the ring/badge when `undefined`. NEVER default a number** (PR3 streak-gate pattern). Stat #2 gated off if absent. |
| **confirm / needs-confirm / verified** | MUST-BUILD (no per-frame confirm lifecycle; `unreviewed` never written) | Add cheap `POST /api/memory/:id/confirm` (set `metadata.status`), mirror `/archive`. Queue = `?status=unreviewed`. **Do NOT show "verified ✓" unless `source==='tool_verified'` or status explicitly set.** |
| **per-frame "why?" trace** | MUST-BUILD backlink (no `trace_id` on frames); session/turn trace is DERIVABLE | Ship session/turn-scoped View B from real traces (add `agent:` tag or `/api/sessions/:id/traces`). **Never synthesize a reason for an unlinked frame.** |
| **"Forgotten" filter** | NO DATA (hard delete = no tombstone) | **Gate the chip off / relabel** — there is no forgotten list. |
**Fabrication-risk flags (gate off, do not invent — PR3 streak precedent):**
1. **Confidence** when undefined → hide ring, don't default.
2. **Freshness** → age, never a stored decay %.
3. **Per-frame trace reason** → only when a real linked trace exists.
4. **"Forgotten" list** → no tombstone; gate off.
5. **Stat-card numbers** → drive from the real query; a real 0 is honest, a fake `3`/`9`/`128` is not.
---
## 4. New primitives to build (recon 03 confirmed: 3 already exist — REUSE)
**Already exist in `components/ui/` (pre-PR3, commit `afe96355`) — do NOT rebuild:**
`ConfidenceBadge`, `EvidenceChip`, `DetailDrawer`, `EvidencePanel`. The handoff's claim they'd
be in `os/warm/` was a location error — they ship today via Memory Center.
**Genuinely new warm atoms for screen 19** (one-line spec each):
- **`ConfidenceRing`** — 38px circular ring; number + 2px border both colored by band (≥85
`--healthy` / ≥60 `--attention` / <60 `--risk`); `CONF` mono caption beneath; **renders the
"no value" neutral state when `confidence` is undefined** (no fabricated number).
- **`ProvenanceLine` (extend, not new)** — add the §5b 3-segment form
`⬡ <id> · source: <src> · ● <freshness>` (`source:`-prefix variant + `●` freshness segment
colored healthy/attention); keep the existing `⬡ source · when` form + `onClick` → button.
- **`TraceEvidenceChip`** (thin wrapper over `ui/EvidenceChip`) — multi-segment trace chip
`confidence X% · source · when · still fresh` in `--surface`/`--line-soft`, `.src` refs mono
`--intel`; only if the plain `EvidenceChip` can't render the multi-segment string directly.
- **`DetailDrawer` (reuse)** — already exists; opened from a clickable memory row for the full
memory detail (confidence/source/evidence/edit). No new build.
*(Net new code: `ConfidenceRing` + the `ProvenanceLine` extension; optional `TraceEvidenceChip`.)*
---
## 5. Risks & open questions for the founder
1. **SSE step pill — do it now (auto_recall slice) or defer?** Recon 02: in-scope only if
paired with the `recallMemory()` return-shape widen (`orchestrator.ts:453-457`). If we want
PR3.5 frontend-light, defer and keep PR3's affordance-only stance. **Recommend: do the
`auto_recall` slice** (honest, load-bearing). Founder call on scope/budget.
2. **Token-family decision (recon 03 §1b):** warm `--intel` (provenance) vs Hive DS `--sem-*`
(confidence bands) currently coexist. Screen 19's confidence rings use warm
`--healthy/--attention/--risk`. Confirm: build `ConfidenceRing` against warm tokens and let
`ui/ConfidenceBadge` keep `--sem-*`, or unify? Default: warm tokens for the ring (matches
the mock 1:1).
3. **Confirm route + status writer.** `POST /api/memory/:id/confirm` is cheap, but nothing
writes `unreviewed` today, so the "Awaiting your confirm" queue may legitimately be empty.
Is that acceptable for launch (real-but-empty), or do we also wire the harvest-commit path
to stamp `unreviewed` (larger)?
4. **Per-frame "why?" backlink.** True per-memory trace needs a `trace_id` on frame metadata
at write time + a resolver (MUST-BUILD). For PR3.5, accept a **session/turn-scoped** View B
(real traces, not per-frame)? Or invest in the backlink this arc?
5. **"Forgotten" filter has no data** (hard delete, no tombstone). Gate the chip off, relabel,
or add a soft-delete tombstone (scope creep)? Recommend: gate off for now.
6. **Two-mind scope for screen 19.** `MemoryCenterApp` is two-mind (personal/workspace) and
the Workspace Memory tab shares `MemoryCenterTab`. Does screen 19 replace `MemoryCenterApp`
wholesale, or layer the Trust view as a new view alongside the existing 7
(`memories·timeline·graph·harvest·weaver·wiki·evolution`)? Affects blast radius.

View File

@@ -0,0 +1,62 @@
# Warm-Hive PR4 — Marketplace + shared install store ("sync") — Build Plan
> Source design: `docs/design_handoff_waggle_app/SCREENS.md §09`. Recon: `docs/redesign-warm-hive/pr4-recon/` (5 docs).
> Branch: `feature/warm-hive-pr4` (off main @ 876807ca). Status: **plan — decisions §3 need founder ratification before feature code.**
---
## 1. The design contract (screen 09)
Skills + connectors + MCP as **one shelf**, agent-searchable. Two variations, **one store**:
- **A — Browse & ask:** centered agent-search bar → suggestion box (connector + skill + tool, each with a "why" + install) · "N in this workspace" count bar · All/Skills/Connectors/MCP filter · card grid (kind badge, name, desc, install count, Add).
- **B — Inline in chat:** the same picker mid-conversation — offer a missing connector with a **vault-aware approval** ("token → your vault") → connected follow-up.
- **CRITICAL — "sync":** one store powers grid + agent-pick + inline. Type-aware one-click: **skill** Add→Adding…→Added (instant) · **connector** Connect→Signing in…→Connected (~1.1s, token→vault) · **MCP** Enable→Enabling…→Enabled. Each fires a toast + updates the count bar. **Installing in any view reflects in all.**
## 2. Current state (grounded in recon — the engine exists; the gap is surface + a store)
| Layer | Reality today | File anchors |
|---|---|---|
| **Match engine** | `searchCapabilities(need)` → ranked candidates across native tools / skills / marketplace, **each with `matchReason` ("why") + `installAction`**. Reachable ONLY as the `acquire_capability` chat tool, which returns markdown and **discards the structured proposal**. | `packages/agent/src/capability-acquisition.ts:179`; tool `skill-tools.ts:406` |
| **Install backends (3, separate)** | skills = FS markdown reloaded into `agentState.skills`; connectors = **vault** credential `connector:<id>`; MCP = `.mcp.json` + live `McpRuntime` (PRO-gated, SecurityGate-scanned). **No unified installed-set.** | `routes/{skills,connectors,mcps}.ts`; `packages/marketplace/src/{db,installer}.ts` |
| **Install verbs** | skill/pkg `POST /api/marketplace/install` (PRO) · MCP `POST /api/mcps/install` (PRO) · connector `POST /api/connectors/:id/connect` (token→vault, ungated). All write the append-only `install_audit` (no `workspace_id`). | `routes/marketplace.ts:181`, `mcps.ts:189`, `connectors.ts:96` |
| **FE grid** | Phase-4B "Extend" surface: 6-facet rail, keyword search, **vertical list** of `ExtensionCard`, federates-at-read into **local `useState`** (discarded on unmount). Only marketplace skill/MCP pkgs install in-place; connectors/MCP = "Open in Hub" deep-links. Legacy tokens. | `MarketplaceApp.tsx` (418 LOC), `extend/ExtensionCard.tsx`, `lib/extension-catalog.ts` |
| **Variation B precursor** | `CapabilityRequestCard` (inline chat) — **skill/marketplace only**, own private phase state, no connector/vault approval, no shared state with grid. Approval channel is **boolean-only** (can't carry a token). | `chat-blocks/CapabilityRequestCard.tsx`, `capability-request-parser.ts`, `confirmation.ts` |
**Net:** the matching brain + the 3 install paths are done and tested. PR4 is (a) a **shared FE install store** the three surfaces subscribe to, (b) **Variation A** UI (agent-search bar + suggestion box + count bar + grid with type-aware verbs/micro-states), (c) one **new route** exposing the existing engine, (d) **Variation B** completion (connector/MCP + vault approval).
## 3. Decisions — RATIFIED (founder, 2026-06-16): D1 FE-derived global count · D2 collapse to 4 shelves · D3 in-place (OAuth→Hub fallback) · D4 sync = grid+pick+inline. D5D7 defaults stand.
| # | Decision | Recommended (v1) | Why / cost of the alternative |
|---|---|---|---|
| **D1** | "N in this workspace" count bar | **FE-derived global count** from existing per-type list endpoints | Real per-workspace tracking needs a new `workspace_id` migration across 3 backends + the OSS-excluded `install_audit` — large, and the substrate isn't workspace-partitioned. Honest label: "installed" not "in this workspace" if global. |
| **D2** | Facets | **Collapse to the design's 4: All / Skills / Connectors / MCP** | Matches screen 09 ("one simple shelf"). Agents/Models/Templates keep their dedicated hubs (already deep-links). Keeping the 6-domain superset diverges from the design. |
| **D3** | Connector/MCP in the grid | **In-place Connect/Enable** reusing the real vault/runtime + SecurityGate; **OAuth-only connectors fall back to the Connector Hub** | Design clearly wants in-place. Must NOT bypass security/OAuth gating. Token-paste connectors + MCP Enable work inline; OAuth-only can't finish inline. |
| **D4** | Sync scope | **Grid + agent-pick + inline-chat** via an FE store; **re-hydrate on nav** so external Hub installs reconcile | Refactoring ConnectorsApp/MCPHubApp/skills-center to also push to the store is a much bigger blast radius — defer. |
| D5 (default) | Suggestion "why" | engine's deterministic `matchReason` (free, honest, no model call) | LLM-generated why is prettier but adds a model call + latency. |
| D6 (default) | Card install count | **omit the social `downloads` count** (local-single-user ≈ 0; honest-stats ethos) — show only a real Installed badge | — |
| D7 (default) | PRO-gated items | show as installable with an **upsell on click** (current behavior; moat: skills/MCP are the upgrade trigger) | — |
## 4. Architecture
- **Shared store (the spine):** new `apps/web/src/providers/InstallProvider.tsx` + `useInstallStore()` — an `installed` Map + `installing` Set keyed by `extension-catalog` namespaced ids; a **type-aware install dispatcher** (skill→`installPack`/skill; connector→`connectConnector` (vault); mcp→`installMcp`; pkg→`installMarketplacePackage`); a **count selector**; **optimistic flip + reconcile** (roll back on 403/SecurityGate, route to Upgrade/Approval). Hydrate from `GET /api/skills`, `/connectors`, `/mcps`, `/marketplace/installed` (no new read route for v1).
- **One new server route:** `POST /api/marketplace/agent-search` `{need}` → wraps the existing `searchCapabilities()` (lift the dep assembly from `local/index.ts:620`), returns the **structured** proposal + a `pickOnePerKind()` grouping (connector+skill+tool). Mirrors `GET /api/skills/suggestions`. tsx-only → must `tsc -p packages/server`.
- **Reuse:** `extension-catalog.ts` normalizers (flip `installable:true` for connector/mcp once the store can install them); `CapabilityRequestCard` for inline; warm components `components/os/warm/*` + warm tokens.
## 5. Phased plan (TDD; commit per phase; FE `tsc -p apps/web/tsconfig.app.json` + vitest each)
- **Phase A — shared install store** (`InstallProvider`/`useInstallStore`): hydrate, type-aware dispatcher, count selector, optimistic+reconcile, toasts. Unit-tested in isolation. *The spine everything else binds to.*
- **Phase B — Variation A grid**: `ExtensionCard` type-aware verbs (Add/Connect/Enable) + idle→in-progress→done micro-states reading the store; `MarketplaceApp` sources from the store, adds the **count bar** + 4-shelf filter (D2) + **card grid** + warm tokens; in-place connector/MCP (D3). Rewrite the 11 phase4b tests to the new contract.
- **Phase C — agent-search**: `POST /api/marketplace/agent-search` (+ `pickOnePerKind`) wrapping `searchCapabilities`; the centered **AskBar** + **suggestion box** (connector/skill/tool, each "why" + install via the store) + example chips.
- **Phase D — Variation B inline**: extend `CapabilityRequest.kind` to `connector`/`mcp`; **vault-aware approval row** (token→vault; approval channel stays boolean, FE calls `connectConnector` directly — no token on the approval channel); route installs through the shared store so chat ↔ grid ↔ count bar stay in sync; consolidate `ApprovalGate`/`InlineApprovalCard`.
- **Phase E — review + live smoke**: adversarial review workflow; live smoke (grid install reflects in count bar + inline; agent-search returns real picks; 0 console errors).
## 6. Key risks (from recon)
- **11 phase4b tests** pin current testids + the `{type,limit:30}` call shape → Phase B rewrites them (expected, not collateral).
- **Optimistic flips must reconcile** with server truth (install can 403 / SecurityGate-block *after* the flip) — across grid AND inline; count bar must not show a gate-rejected item.
- **Don't bypass Hub security/OAuth** — in-place Connect/Enable reuse the real gated paths; OAuth-only → Hub fallback.
- **`install_audit` is OSS-excluded + append-only** — never the source of truth for installed state; read per-type list endpoints.
- **Honest-stats / mind-isolation ethos** — global count labeled honestly (D1); omit social download counts (D6).
- Server routes run via `tsx` (not typechecked by `npm run build`) → explicit `tsc -p packages/server` gate on the new route.
## 7. Verification gates (per phase + final)
`tsc -p apps/web/tsconfig.app.json` 0 · `tsc -p packages/server` 0 (new route) · FE vitest green (incl. rewritten phase4b) · `npm run lint` · live smoke: install in grid → count bar + inline both reflect; agent-search returns connector+skill+tool with "why"; vault-aware inline approval; 0 console errors.

View File

@@ -0,0 +1,57 @@
# Warm-Hive PR5 — Settings (models-first + failover) · Onboarding (6-step + model gate) — Build Plan
> Source design: `docs/design_handoff_waggle_app/SCREENS.md §11` (Settings) + `§10` (Onboarding). Roadmap: `BUILD-PLAN.md §6` (PR5 = screens 11·10). Branch: `feature/warm-hive-pr5` (off main @ 8b4ba60a). Status: **plan — §3 decisions need founder ratification before feature code.**
> Recon: 5-reader workflow `wf_11242c36-d4a` (5/5 areas), grounded vs live code.
> **Status update (founder-ratified): D1 = BYO-key; D2D12 proceed as recommended. Building.**
## 1. The design contract
- **§11 Settings:** calm, progressive-disclosure; **Models leads.** Left rail General·Models·Permissions·Plan·Team·Backup·Advanced + a top-right **Show: Essential/Standard/Everything** (Advanced only at Everything). **Models:** a "≥1 working model" banner + the **Model-pilot failover chain** Primary→(if it errors)→Fallback→(daily budget)→Budget + a daily-budget input (switch at 80%) + read-only provider-key list (manage in Vault) + a local-models list. Maps to `defaultModel/fallbackModel/budgetModel + budgetThreshold/dailyBudget`.
- **§10 Onboarding:** ≤2-min first-run ending **inside the work.** 6 full-screen steps: 1 Welcome · 2 About-you (name+role+team-size) · 3 **MODEL GATE** (API-key tab: provider chips + live-validated key→vault · OR local-model tab: detect Qwen / pull Llama — **hard gate: can't proceed without ≥1 working model**) · 4 Import · 5 Template · 6 First-task → opens into the workspace. The gate's **permanent home is Settings→Models** — same mechanism, shared.
## 2. Current state — **the engine + Settings are ~90% built; PR5 is reskin + reconcile + the onboarding gate**
| Layer | Reality today | Anchors |
|---|---|---|
| **Failover engine** | **Fully live at runtime** — budget-threshold switch + smart cost routing + error-triggered fallback + budget-model context-compression, all per-request. NOT a UI concept. | `chat.ts:520-545,1112-1117,1374-1389`; `smart-router.ts:12-25` |
| **Config + persistence** | All 5 fields (+`budgetHardCap`) have getters/setters + full GET/PUT `/api/settings`. | `core/config.ts:87-165`; `routes/settings.ts:66-129` |
| **Settings shell** | 8-tab rail (General·Models·Billing·Permissions·Team·Backup·Enterprise·Advanced) + `ModelPilotCard` (3-lane Primary/Fallback/Budget + threshold slider) leads Models + read-only provider-key list. All 6 §11 "other tabs" built. | `SettingsApp.tsx:35-46,362-477,481-1037`; `ModelPilotCard.tsx` |
| **Progressive disclosure** | EXISTS as the dock tier (Essential/Standard/Everything via `getSettingsTabsForTier`) — surfaced as a **"Dock Experience" `<select>` in the General body**, not a top-right rail control. Advanced shows at **Standard** (design wants Everything-only). | `lib/settings-tier-filter.ts:19-84`; `SettingsApp.tsx:244-260` |
| **Provider catalog + key + local** | `GET /api/providers` (13 providers, `hasKey`, model catalogs, **live Ollama discovery**); `POST /api/settings/test-key` (**format-only**); a real **live Anthropic 1-token probe** exists but is private to `/health`; `/api/local-inference/{hardware,models,status,pull}`; vault key CRUD. | `routes/providers.ts:240-294`; `settings.ts:200-213`; `index.ts:2280-2331`; `local-inference.ts` |
| **Onboarding** | **5-step** chain (first-launch·who-are-you·memory-import·workspace-create·ready), **NO model gate, NO template step**, first-task hardcoded. Nav is index-derived off `STEP_NAMES` (safe to re-key). Step-2 (name+role+team-size) + Import already built/rich. 15 templates exist in `constants.ts` but unwired. | `OnboardingWizard.tsx:35`; `onboarding/{WhoAreYouStep,ImportStep,constants}.tsx` |
**Net:** PR5 = (a) **one shared `ModelGate`** component (API-key validate→vault + local detect/pull + a `hasWorkingModel` signal), (b) **Settings reskin/reconcile** (warm tokens, Models-leads, "≥1 working model" banner, local-models list, label/disclosure tidy), (c) **Onboarding re-key 5→6 + the hard model gate + template + first-task steps + warm restyle**. The only backend bit is *optionally* generalizing the live key-probe (D3).
## 3. Decisions — **NEED FOUNDER RATIFICATION before feature code** (recommend-and-proceed unless you object, except D1)
| # | Decision | Recommended | Why |
|---|---|---|---|
| **D1** | **BYO-key vs Waggle-metered** (DESIGN_POV §4 — the strategic gate; reshapes the gate copy + Billing/Usage PR7) | **Ship BYO-key** (paste a provider key→vault, or a local model) | 100% of the backend (vault, anthropic-proxy, local-inference) is BYO-shaped; metered = new billing/usage-cap plumbing. Matches the local-first promise + §10 copy. Metered = a later additive tab. **Founder strategic call.** |
| **D2** | Hard-gate strictness + escape | **Hard** (Continue disabled until ≥1 working model) + **one** "I'll do this later" → dismiss onboarding to Home with a persistent "no model yet" banner | A zero-model first-task instantly errors (the cold-start churn DESIGN_POV §2 warns of); a zero-escape gate traps lookers. The dismiss-to-Home compromise keeps the gate's intent without a dead-end. |
| **D3** | "✓ valid" validation depth | **Generalize the proven Anthropic 1-token live probe** to all providers behind `POST /api/settings/test-key` (live mode, 5s timeout + short-TTL hash cache); format-only fallback for providers w/o a cheap probe | Format-only would let a bad key pass the hard gate — defeating the "✓ valid" promise + the no-fabrication contract. The live pattern already exists (powers `/health`). **The one real backend addition in PR5.** |
| **D4** | Onboarding step shape | **6 steps: Welcome · About-you · Model-gate · Import · Template · First-task**; fold workspace-creation into the Template step (template choice → `createWorkspace` w/ mapped persona → first-task opens it) | Matches §10 1:1 (no standalone workspace step); index-derived nav makes re-keying safe; reuses `handleCreateWorkspace`. |
| **D5** | Templates on step 5 | The design's **curated 6** (Research/Strategy/Engineering/Sales/Writing/Custom) mapped to existing template ids + `TEMPLATE_PERSONA`; the other specialists via the workspace gallery later | §10 names 6 for the ≤2-min flow; 15 fights "calm/fast". Data + persona map already exist (curation, not new data). |
| **D6** | Settings disclosure control | **Reuse** the existing `useOnboarding().tier` state; relocate/re-skin it as a top-right **Show:** segmented control. No second persistence key. | A second disclosure axis doubles state + contradicts the single global "how much to show" dial (already gates the dock). Shrinks the work to relocate+reskin. |
| **D7** | Advanced visibility | Move `advanced` from STANDARD → **Power/Everything-only** in `settings-tier-filter.ts` | Matches the design's "depth when you ask for it"; keeps Standard calm. One-line behavior change to a shipped filter contract. |
| **D8** | Rail labels | Rename **Billing → "Plan"**; **keep Enterprise** a separate tab (don't merge into Plan) | Rename = zero-risk, design-aligned. Merging Enterprise (KVARK config + audit gate) into a sales CTA risks regressions — defer. |
| **D9** | Backup duplication | Settings Backup tab **reuses the richer standalone `BackupApp`** (embed or deep-link, PR4 Hub pattern) | `BackupApp.tsx` is strictly more capable (history/retry/metadata); two backup UIs = drift (CLAUDE.md §4). |
| **D10** | Language selector (§11 General) | **DEFER** (note as out-of-scope) | No i18n/locale layer exists (0 grep hits); a real selector = a translation subsystem, unjustified now. Add the "local-first on-always" copy (trivial). |
| **D11** | Warm-token sweep | **In scope** — kill hardcoded `hsl()` theme swatches + desaturate emerald/violet/amber/honey status colors to warm semantics | PR5 edits these files heavily; one pass keeps the "single honey accent" honest. (Teams-violet needs a remap call.) |
| **D12** | Local-model pull UX | **Synchronous spinner** + success/fail toast (reuse `POST /api/local-inference/pull`); defer streamed progress | Streaming pull = net-new SSE backend off the critical path; most first-run users pick a cloud key. |
## 4. Architecture
- **Shared `ModelGate` (the spine):** new `apps/web/src/components/os/model-gate/ModelGate.tsx` + `useHasWorkingModel()` — two tabs (API-key: provider chips + writable validated field → vault via `PUT /api/settings`; local-model: `getLocalInferenceStatus` detect + `pullLocalModel`), and `hasWorkingModel = activeProviders.length>0 || localStatus.totalLocalModels>0` (key "present"→"valid" once the live test passes). Mounted in **both** Onboarding step 3 AND as the Settings→Models lead (reskinning the existing `ModelPilotCard` + key-list into it). **Do NOT recreate** config getters, `chat.ts` failover, the providers route, or `ModelPilotCard`'s persistence contract.
- **Backend (D3 only):** extend `POST /api/settings/test-key` with a live mode generalizing `validateAnthropicKey()` (1-token ping, !401/!403 = valid, 5s timeout, hash-keyed short TTL); `tsc -p packages/server`.
## 5. Phased plan (TDD; commit per phase; FE `tsc -p apps/web/tsconfig.app.json` + vitest each)
- **Phase A — shared `ModelGate` + `useHasWorkingModel` + (D3) live key-validate.** The spine; unit-tested in isolation (mock providers/local/validate).
- **Phase B — Settings reskin + reconcile.** Mount `ModelGate` as Models lead; "≥1 working model" banner; dedicated local-models list; warm-token sweep; Models-leads default tab; Billing→Plan (D8); Advanced→Everything-only (D7); top-right Show: control (D6); Backup reuses `BackupApp` (D9); General local-first copy (D10). Extend SettingsApp tests.
- **Phase C — Onboarding 6-step + hard gate.** Re-key `STEP_NAMES` → 6 (D4); mount `ModelGate` as step 3 with the hard gate + "later"→Home+banner (D2); Template step (curated 6, wire persona + `createWorkspace` + `templateId`) (D5); First-task step (ask + suggested chips from `TEMPLATES[].hint` → seed first message → open workspace); warm full-screen restyle. Extend onboarding tests.
- **Phase D — adversarial review + live smoke.** Review (correctness/security/honest-stats/design-fidelity); live smoke (onboarding blocks without a model + completes into the workspace with one; Settings Models reskin + banner; 0 console errors).
## 6. Key risks
- **Don't recreate the failover engine / config / providers route / `ModelPilotCard` persistence** — reuse (CLAUDE.md §3.3/§8). The biggest risk is mistaking the ~90%-built Settings for net-new and rebuilding it.
- **No-fabrication on "✓ valid"** — never show a confident valid on an unchecked/format-only key (carry the PR3/PR3.5 honesty contract).
- **Onboarding re-key** is safe (index-derived nav) but shifts progress-dots/Back/Continue ranges + the `?forceWizard` latch + step-clamp — verify resume.
- **Server route via tsx** (test-key live mode) not typechecked by `npm run build` → explicit `tsc -p packages/server`.
- **D1 (BYO vs metered)** silently shapes the gate copy — ratify before the gate ships.
## 7. Verification gates (per phase + final)
`tsc -p apps/web/tsconfig.app.json` 0 · `tsc -p packages/server` 0 (D3 route) · FE vitest green · `npm run lint` (no new errors) · live smoke: onboarding hard-gate blocks w/o a model + lands in the workspace with one; Settings Models reskin + working-model banner; 0 console errors.

View File

@@ -0,0 +1,126 @@
# Warm-Hive PR6 — Remaining surfaces (the long tail) — Build Plan
> Source design: `docs/design_handoff_waggle_app/SCREENS.md §04·06·07·08·12·15·16·17·18` + `design-files/screens/*.html`. Roadmap: `BUILD-PLAN.md §6` (PR6 = screens 06·07·08·16·12·17·18·15·04). Branch: TBD off main (PR5 stream merges first). Status: **plan — §3 decisions need founder ratification before feature code.**
> Recon: 9-screen grounded recon (one reader per screen), every claim verified vs live code with file:line. The notable corrections are in §8 (honesty log).
> **Scope reframe vs the roadmap:** PR6 as written is **9 heterogeneous screens** — too big for one PR. §2.5 recommends a **3-way sub-PR split**.
## 1. The design contract
- **§04 All workspaces** (`workspaces.html`): the full workspace shelf — Grid (default) / Table toggle, search + storage filter pills (All/Local/Virtual/Team), cards with hex avatar, **storage badge** (Local=honey, Virtual=intel, Team=healthy), summary, meta (memories · "agent live" · "N to review" · "Open →"). Answers "where are all my workspaces?" (Home = your day; this = everything). Reachable from rail/⌘K/switcher.
- **§06 Launcher (AI-OS)** (`launcher.html`): detect & launch external coding agents into a workspace's shared memory. **Variation A (Launch)** = primary (tool cards, launch-with-task box, "Running now" PID panel). **Variation B (How memory is shared)** = a 3-node flow diagram + 3 explanation cards + a provenance example, on a segmented toggle.
- **§07 Storage & Files** (`storage.html`): make the filesystem legible. **Variation A (Where it lives)** = live on-disk tree card + three storage-type cards (Virtual/Local/Team) + local-first reassurance. **Variation B (Files)** = classic browser with a **Source/provenance column** ("made by Claude Code"). Both real, one screen, segmented toggle. Maps to `StorageType = 'virtual'|'local'|'team'`.
- **§08 Power surfaces** (`surfaces.html`): the long tail (Tools · Automations · Approvals · Vault · Usage) on **ONE reusable row/card/toggle set**, behind ⌘K. Per-model spend bars + budget on Usage; Rotate/Reveal/Renew on Vault; color-stratified risk badges on Approvals; enable/pause on Automations.
- **§12 Skill evolution + diffusion** (`evolution.html`): **A (version ladder)** = a skill card with v1/v2/v3 quality scores + improvement notes + a "GEPA optimized · judged by 3 models" provenance badge. **B (diffusion)** = skills/recipes propagating across agents/workspaces via waggle-dance signals. Both views are real tabs per §371.
- **§15 Habit loop** (`habit.html`): **NOT a standalone screen** — a principles doc whose mechanics live on Home: 🔥 daily streak chip, "while you slept" variable-reward chips, gentle nudges (earned), compounding memory depth. Ethics note (no dark patterns, all toggle-off-able).
- **§16 App surfaces (sextet)** (`appsurfaces.html`): six real routes on one rail — Agents (swarm CTA), **Room** (shared stage of turns + participants panel), **Artifacts** (3-col card grid + provenance), **Mission Control** (hive health + local services list), Timeline (one chronological stream), **Profile** ("what Waggle knows about you" facts + persona chips).
- **§17 Benchmarks** (`benchmark.html`): honest positioning. **A (Capabilities)** = 11-row matrix (Waggle column honey-highlighted; ●/◐/○; competitors deliberately given ● on terminal coding). **B (Memory SOTA)** = LoCoMo bars (87.66/81.95/78.05/62.47) + stats (+5.71pp, p<10⁻⁵, 92.75%, 100% local). ⌘K-only, both views real tabs. Numbers must stay accurate to the `hive-mind` benchmark.
- **§18 Platform & roadmap** (`platform.html`): the Tauri desktop story + roadmap. **4 views** (all real tabs per §371): Desktop (macOS↔Windows titlebar toggle), Boot ("warming the hive" checklist), Coming-next channel cards, and a **custom 404** ("This cell of the hive is empty."). ⌘K-only.
## 2. Current state — **grounded, one row per screen** (verified vs live, 2026-06-17)
| Screen | Design intent (1-line) | Reality today (file:line) | State | Gap | Effort |
|---|---|---|---|---|---|
| **04 All workspaces** | Full shelf: grid/table + storage pills + rich cards | `App.tsx:66` `/workspaces``Navigate to /home`; list lives only as `HomeCockpit` RecentWorkspacesPanel (`HomeCockpit.tsx:166-226`, ~2 cards). `adapter.getWorkspaces()` (`adapter.ts:491-500`), `Workspace` type w/ `storageType/memoryCount/sessionCount/health` (`types.ts:239-260`), `useWorkspaces` hook all present. No `AllWorkspacesApp`, no ⌘K/rail entry. | **NET-NEW** | large | **M** |
| **06 Launcher** | Variation A launch + Variation B memory-sharing explainer | `LauncherApp.tsx:1-525` feature-complete A (detect/launch/hooks/processes/prompt-pass). Routed `LauncherRoute`, in `App.tsx:82`, `command-catalog.ts:72`, dock `dock-tiers.ts:93`. Backend `/api/tools/{detect,launch,processes,kill,hooks}` (`adapter.ts:2408-2523`). **Variation B absent.** | **EXISTS (PARTIAL)** | small | **S** |
| **07 Storage & Files** | A "where it lives" + B file browser w/ provenance col | `FilesApp.tsx:1-793` (B browser; **no Source column**, headers Name/Size/Modified `:539-544`), `FilesAppTabs.tsx:1-99` (storage-type tabs — orthogonal to A/B), `FilesRoute.tsx`, `STORAGE_LABELS` (`file-utils.ts:52-56`). **Variation A absent** (no on-disk tree, no storage-type cards). | **PARTIAL** | medium | **M** |
| **08 Power surfaces** | 5 tabs on ONE row/card/toggle set, ⌘K shell | 5 separate apps at separate routes: `VaultApp.tsx` (`/settings/vault`; Reveal+Rotate-link, **no Renew**), `ApprovalsApp.tsx` (`/approvals`; amber text, **no color-stratified badges**), `AutomationCenterApp.tsx` (`/automations`; toggles work), `TelemetryApp.tsx:1-145` (`/settings/usage`; **no per-model bars, no budget**), `CapabilitiesApp.tsx` Tools tab ≠ integrations. Backend all present (vault/approval/automations/cost/events). | **PARTIAL** | medium | **M** |
| **12 Evolution** | A version ladder + B diffusion maps | `EvolutionTab.tsx:1-1000` full run-review (list/detail/accept/reject/SSE/NewRunModal), rendered as Memory tab (`MemoryCenterApp.tsx:35,180`). Backend `evolution.ts:1-662` complete. **No version-ladder skill-card framing; no diffusion (B) UI.** | **PARTIAL** | medium | **M** |
| **15 Habit loop** | Mechanics on Home (streak/overnight/nudge/depth) | `StreakChip.tsx:1-26` exists but `SHOW_STREAK=false`, `STREAK_DAYS=0` hardcoded (`HomeCockpit.tsx:49-50`). `OvernightHero.tsx`+`RunChip.tsx` correct & live (`HomeCockpit.tsx:293-330`). `NotificationInbox.tsx:1-80` exists but **not wired to Home**. No `streak/memoryCount/nudge` on `HomeBriefing` (`types.ts:317-329`). Memory-depth: **no backend**. | **PARTIAL** | medium | **M** (backend-blocked) |
| **16 App surfaces (sextet)** | 6 routes, consistent rows/badges/provenance | All six routed: `AgentsApp` (swarm CTA soft-routed `:158`), `RoomApp.tsx:1-261` (**single-col tile grid; no participants panel/turn stage**), `ArtifactCenterApp.tsx:1-386` (**vertical list; not 3-col grid**), `MissionControlApp.tsx:1-227` (fleet/team/activity tabs; **no unified services list**), `TimelineApp.tsx:1-241` (**ALIGNED**), `UserProfileApp.tsx:1-532` (**no "what Waggle knows" read-only facts**). | **PARTIAL** | medium | **M** |
| **17 Benchmarks** | Capabilities matrix + Memory SOTA bars, ⌘K-only | **Nothing exists.** No grep hits for "benchmark" in `apps/web/src`; not in `dock-tiers.ts` AppId, `routes.ts`, `command-catalog.ts`, `App.tsx`. Design fully specified in `benchmark.html` (220 lines, static data). No backend needed. | **NET-NEW** | net-new | **M** |
| **18 Platform & roadmap** | 4 views (Desktop/Boot/Roadmap/404), ⌘K-only | **Nothing exists.** No `PlatformApp`/`PlatformRoute`, not in AppId/routes/⌘K/`App.tsx`. 404 is generic `NotFound.tsx:1-24` (no honey hex, no spec copy). Design in `platform.html`. No backend needed. | **NET-NEW** | net-new | **M** |
**Net:** PR6 is **2 net-new informational surfaces** (17, 18 — pure UI, static data, no backend), **1 net-new data surface** (04 — backend already feeds it), **5 reskin/reconcile-in-place** of shipped apps (06 add Variation B; 07 add Variation A + provenance col; 08 add the 5 missing features within existing routes; 12 add version-ladder framing; 16 reskin 5 of 6 + 2 small builds), and **1 backend-blocked Home wiring** (15 — flip the streak gate + the depth/nudge mechanics need new backend fields, so the flip-now slice is small and the rest defers).
### 2.5 Recommended sub-PR split (the core recommendation)
PR6 spans nine screens with sharply different shapes — net-new builds, in-place reskins, and a backend-blocked feature. One PR would be unreviewable and would couple low-risk reskins to backend-dependent work. **Recommend 3 cohesive sub-PRs, ordered by value/risk:**
| Sub-PR | Screens | Scope theme | Why this grouping | Effort |
|---|---|---|---|---|
| **PR6a — Net-new informational surfaces** | **17 Benchmarks · 18 Platform** | Two pure-UI, static-data, ⌘K-only surfaces. New `*App` + `*Route` + register in `routes/index.ts`+`App.tsx`+`command-catalog.ts`; reskin `NotFound.tsx`. | **Lowest risk, highest standalone value** — zero backend, no existing-component blast radius, ships the public-facing positioning + desktop story. Benchmarks shows the SOTA proof we already published. Cleanest first PR. | **SM** |
| **PR6b — In-place reskin + feature-fill of shipped apps** | **06 Launcher · 07 Storage&Files · 08 Power surfaces · 12 Evolution · 16 App-surfaces** | Reskin shipped apps to spec + fill named missing features, each at its current route. No architectural unification, no new backend (except §3 D3/D6 cheap routes). | **The bulk of the value, all reuse.** Every screen already has a working component + backend; this is "make it match the doc." Largest review surface, so it is its own PR. Internally phaseable (one screen per commit). | **L** (M each ×5) |
| **PR6c — All workspaces + Habit-on-Home** | **04 All workspaces · 15 Habit loop** | The two surfaces that need a backend field/route to be honest: 04 = net-new view over existing `getWorkspaces`; 15 = flip the streak gate + (deferred) depth/nudge fields. | **Backend-coupled, so isolated from the reskins.** 04 is a real new screen but its data is ready; 15's honest slice is tiny (gate flip) with the rest gated behind new `HomeBriefing` fields. Grouping the two data-shaped surfaces keeps the backend touch in one PR. | **M** |
**Sequencing rationale:** PR6a first (de-risked, shippable alone, no dependencies). PR6b second (the meat; depends on PR1/PR2 tokens + ⌘K being merged, which they are). PR6c last (carries the only net-new backend fields — §3 D9/D10 — and is the most likely to spill into a follow-up). Each sub-PR is independently revertible. If timeline pressure hits, **PR6c is the natural defer candidate** (04 can ship without a rail entry; 15's flip can wait for the streak backend).
## 3. Decisions — **NEED FOUNDER RATIFICATION before feature code** (recommend-and-proceed unless you object; D1/D8/D11 are strategic)
| # | Decision | Recommended | Why |
|---|---|---|---|
| **D1** | **Sub-PR split** (§2.5) | **Ship the 3-way split: PR6a (17·18) → PR6b (06·07·08·12·16) → PR6c (04·15)** | Nine heterogeneous screens are unreviewable as one PR and couple zero-risk reskins to backend-dependent work. The split groups by risk shape, keeps net-new backend in one PR, and each piece is independently revertible. **Founder structural call.** |
| **D2** | Benchmarks (17) — build vs defer | **BUILD in PR6a** | Pure UI, static data from `benchmark.html`, no backend, self-contained. It surfaces the LoCoMo SOTA proof (87.66, +5.71pp, p<10⁻⁵) that is **already published** — high marketing value at near-zero risk. ⌘K-only keeps the spine calm. |
| **D3** | Benchmarks data source | **Inline constant in `BenchmarkApp.tsx`** (11×6 CAPS + 4 SOTA bars), mirroring `benchmark.html` | Design treats it as static. An API is over-engineering for numbers that change on a benchmark cadence, not a request cadence. Numbers must be **manually refreshed from the `hive-mind` benchmark** (carry a dated comment + the no-fabrication caveat). |
| **D4** | Platform (18) — build vs defer | **BUILD in PR6a** | Pure UI, no backend, self-contained, ⌘K-only. Tells the Tauri desktop story + roadmap and replaces the generic 404 with the on-brand one. Low risk, high polish payoff. |
| **D5** | Platform 404 ownership | **Reskin the standalone `NotFound.tsx`** (honey hex + spec copy + back-to-Home + ⌘K affordance); **do NOT** nest 404 inside `PlatformApp` state | `NotFound.tsx` is the real router catch-all (`App.tsx:94`); a tab inside `/platform` would never catch a bad URL. Keep them split: 404 stays the route fallback, Platform's other 3 views are tabs. |
| **D6** | Platform Boot view vs real boot | **Render Boot as a static showcase tab** in `PlatformApp`; do NOT rewire the live `BootScreen`/AppShell boot gate | The real boot flow is owned by AppShell (PR1 stream). The §18 "warming the hive" view is a marketing showcase, not the runtime gate — duplicating runtime logic risks the cold-load crash class PR3 already fought. Showcase only. |
| **D7** | Launcher (06) Variation B | **Add as a segmented toggle on the same surface** (matches `launcher.html:110-113`); align copy to §06 spec | Design shows a segmented control on one page, not a modal/overlay. All plumbing exists; this is a second view + copy polish. No backend. |
| **D8** | Power surfaces (08) — unify behind ⌘K vs keep separate routes | **Keep the 5 apps at their current routes; reskin to a shared row/card/toggle look. DO NOT unify into one `/settings/power-surfaces` shell in PR6.** | Unifying breaks 4 deep-linked routes (`/settings/vault`, `/approvals`, `/automations`, `/settings/usage`) wired into ⌘K + dock + onboarding. The design's "ONE component set" intent is satisfied by **shared primitives**, not a shared route. Architectural unification is a **post-launch Phase 4B item** — flag it but don't ship it here. **Strategic scope call.** |
| **D9** | Power surfaces (08) feature fill | **In PR6b:** per-model spend bars + daily-budget input (80% warn) on Usage; Renew button on Vault → reuse the Rotate endpoint; color-stratified risk badges (amber=Medium, green=Low) on Approvals; harmonize toggle/row/badge styling | These are the named §08 gaps and are all FE-side over existing backends (`cost/by-workspace`, vault, approval). Renew has no dedicated endpoint — **point it at Rotate** and note the intent for a future phase (no new backend in PR6). |
| **D10** | Storage (07) — A as a tab in `/files` vs new `/storage` route | **Tab within `/files`** via a new `StorageAndFilesApp` wrapper holding the A/B segmented toggle; storage-type tabs (Virtual/Local/Team) live **inside** Variation B (orthogonal) | Design shows one screen with a unified A/B switcher (`storage.html:97-100`). Reuses `FilesRoute`; avoids a second route + a second ⌘K entry. The recon's key correction: the existing storage-type tabs are a **different axis** from A/B — they coexist, not conflate. |
| **D11** | Storage (07) provenance column — source | **Add a Source column to the file table** showing granular creator ("made by Claude Code", "you uploaded", agent name); **gate off when unknown** (no fabricated source) | §07 shows the specific string ("made by Claude Code"). Needs the creator/provenance to exist on the file metadata — **verify the file-tree route carries it; if absent, show "—", never invent** (PR3/PR3.5 no-fabrication contract). Confirm data availability before shipping the column. |
| **D12** | Evolution (12) version-ladder grouping | **In PR6b:** aggregate `EvolutionRun` rows by `(target_kind, target_name)` into skill cards; render the version ladder (score bar + delta + improve-note); **DEFER Variation B (diffusion maps) to PR7/8** | A is a reskin over real run data (`evolution.ts`). B (diffusion) is **net-new UI** (propagation maps + adopter lists + waggle-dance signal correlation) — out of proportion to a reskin. Signals exist in backend but the map component is a distinct feature. |
| **D13** | Evolution (12) "GEPA · judged by 3 models" badge | **Show only when derivable from `gate_reasons`/status; otherwise omit** | The backend does not explicitly store this provenance string. Inferring "best" from `(status=deployed, highest delta_accuracy)` is honest; **fabricating "judged by 3 models" when the gate roster isn't recorded is not.** Gate the badge off when the metadata is absent. |
| **D14** | All workspaces (04) — Grid vs Table MVP | **Ship Grid (default) only in PR6c; Table as a fast-follow** | Design names Grid as the ship variation (§366); Table is the alternate. Grid-only is the ≤2-screen MVP; the toggle scaffold can land with Table stubbed/deferred. |
| **D15** | All workspaces (04) — nav entry | **Add an "All workspaces" entry to ⌘K (Jump group, after Home) in PR6c**; replace the `App.tsx:66` `/workspaces``/home` redirect with the real route | §04 says it's reachable from rail/⌘K/switcher. ⌘K is the cheapest discoverable entry (no spine change — Home stays the day view). Rail entry is out of scope (5-item spine is locked). |
| **D16** | All workspaces (04) — empty state | **Surface a first-run empty state** ("Create your first workspace" CTA) rather than gating behind Home | A direct `/workspaces` visit (or ⌘K) on a zero-workspace install must not dead-end. Reuse the Home create-workspace flow's dialog. |
| **D17** | Habit (15) — streak gate flip | **In PR6c:** flip `SHOW_STREAK=true` **only after** a real `streak` field lands on `HomeBriefing` (backend); until then leave gated (no fabricated streak) | A hardcoded `STREAK_DAYS=0` or invented count violates the no-fabrication contract (PR3 streak precedent — this is literally that precedent). The flip is one line; the **backend streak calculation is the dependency** — confirm owner + timing. |
| **D18** | Habit (15) — memory-depth + nudges | **DEFER memory-depth ("+14 this week · top 8% by depth") to post-PR6** (needs a new `/api/memory/stats` shape + `HomeBriefing` fields); **wire gentle nudges only if `NotificationInbox` filtering lands**, else defer | Depth requires net-new backend (no `memoryCount`/`depthRank` today). Nudges must be "earned" per §15 ethics — surfacing all notification types uniformly is the dark-pattern the design warns against. Both are backend-shaped; keep PR6c's habit slice to the honest streak flip + the already-correct OvernightHero. |
| **D19** | App-surfaces (16) builds vs reskins | **In PR6b:** reskin all six to warm tokens; **build** the Room participants-panel/turn-stage (two-col) + Artifacts 3-col grid + Profile "what Waggle knows" read-only facts; **promote** the Agents swarm CTA; Mission Control = relabel tabs (don't rebuild as a flat services list) | These are the named §16 gaps. Room/Artifacts/Profile are real layout builds; Agents/Mission-Control are reskins/relabels. Mission Control's flat-services-list redesign is larger than a reskin — **relabel + reorder tabs to lead with Services**, defer the full rebuild. |
| **D20** | Profile (16) "what Waggle knows" facts source | **Derive from stored `profile.identitySuggestions` first; only add `/api/profile/facts` if that's insufficient** | Avoids a net-new route if the data already exists in the profile blob. Confirm the suggestions field carries displayable facts before committing to a new endpoint. **No fabricated facts** — render only what the profile actually holds. |
| **D21** | Warm-token sweep | **In every sub-PR, sweep the touched files** — desaturate emerald/violet/amber/sky status colors (still hardcoded in `dock-tiers.ts` color fields + the older apps) to warm semantics; verify `--honey/--intel/--healthy/--attention/--risk` usage matches the mocks | PR6 edits these surfaces heavily; one pass per file keeps the "single honey accent" honest. The sextet + power surfaces still carry pre-warm tier colors (`text-violet-400`, `text-emerald-400`, etc.). |
## 4. Architecture
- **PR6a (net-new, no backend):**
- `apps/web/src/components/os/apps/BenchmarkApp.tsx` (state-toggled Capabilities/SOTA views; inline `CAPS` 11×6 + `SOTA_BARS` 4-bar constants, dated, with the no-fabrication caveat verbatim) + `routes/BenchmarkRoute.tsx` (thin wrapper, PR1a §5.1 pattern). Register in `routes/index.ts` + `App.tsx` (`/benchmarks`) + `command-catalog.ts` (Power group, ⌘K-only).
- `apps/web/src/components/os/apps/PlatformApp.tsx` (3 tabs Desktop/Boot/Roadmap via a `view` enum; Desktop has a macOS↔Windows titlebar toggle) + `routes/PlatformRoute.tsx` + register `/platform` (⌘K-only). Reskin `pages/NotFound.tsx` (honey hex + "This cell of the hive is empty." + back-to-Home + ⌘K).
- **PR6b (reskin-in-place, reuse — DO NOT recreate the backing apps/routes/backends):**
- **06:** extend `LauncherApp.tsx` with a Variation B view behind a segmented toggle (flow diagram + 3 cards + provenance example). No route/backend change.
- **07:** new `StorageApp.tsx` (Variation A: live on-disk tree card + 3 storage-type cards, fed by `GET /api/workspaces/:id` + the file-tree route) + new `StorageAndFilesApp.tsx` wrapper (A/B segmented toggle, hosts `FilesApp` for B) routed via `FilesRoute`; add a Source column to `FilesApp` (`:539-544`, gated when provenance absent).
- **08:** add per-model bars + budget UI to `TelemetryApp`; Renew→Rotate in `VaultApp`; color-stratified risk badges in `ApprovalsApp`; extract shared `<SurfaceRow>/<SurfaceToggle>/<RiskBadge>` warm primitives (the "ONE component set") and adopt across the 5 apps. **No route consolidation (D8).**
- **12:** in `EvolutionTab.tsx`, add a skill-card aggregation + version-ladder render over existing `EvolutionRun` data; provenance badge gated (D13). No backend.
- **16:** reskin six apps; build Room two-col (participants panel + turn stage), Artifacts 3-col grid, Profile read-only facts section; promote Agents swarm CTA; relabel Mission Control tabs.
- **PR6c (data surfaces, the only net-new backend):**
- **04:** new `AllWorkspacesApp.tsx` (grid + search + storage filter pills + cards w/ actions via `useWorkspaces`/`adapter.getWorkspaces`/`ShellContext.selectWorkspace`) + `routes/WorkspacesRoute.tsx`; replace `App.tsx:66` redirect; add ⌘K entry. Reuse `WorkspaceActionsMenu` for row actions. No new backend (all routes exist: `GET /api/workspaces`, `DELETE/PATCH /api/workspaces/:id`).
- **15:** flip `SHOW_STREAK` once a real `streak` lands on `HomeBriefing` (the **one backend field** — calculation owner TBD, D17); OvernightHero is already correct; depth/nudges deferred (D18).
## 5. Phased plan (TDD; commit per phase/screen; FE `tsc -p apps/web/tsconfig.app.json` + vitest each)
**PR6a**
- **A1 — Benchmarks.** `BenchmarkApp` + route + ⌘K register; inline data constants; dark+light; matrix + SOTA-bar render; unit test (renders both views, honey-highlight on Waggle column/bar).
- **A2 — Platform + 404.** `PlatformApp` 3 tabs + route + ⌘K register; reskin `NotFound`; unit test (tab switch, titlebar toggle, 404 affordances).
**PR6b** (one commit per screen; each: reskin + named feature + tests)
- **B1 — Launcher Variation B** (segmented toggle + flow/cards/provenance + copy).
- **B2 — Storage Variation A + provenance column** (`StorageApp` + `StorageAndFilesApp` wrapper + `FilesApp` Source column gated; confirm D11 data first).
- **B3 — Power surfaces feature-fill** (per-model bars + budget; Renew; risk badges; shared primitives — D8/D9/D21).
- **B4 — Evolution version ladder** (aggregate + ladder + gated badge — D12/D13).
- **B5 — App-surfaces sextet** (reskin 6 + Room/Artifacts/Profile builds + Agents CTA + Mission Control relabel — D19/D20).
**PR6c**
- **C1 — All workspaces** (`AllWorkspacesApp` + route replacing redirect + ⌘K entry + grid + actions + empty state — D14/D15/D16).
- **C2 — Habit streak flip** (flip `SHOW_STREAK` gated on the real `streak` field; verify OvernightHero; depth/nudges deferred — D17/D18).
**Each sub-PR ends with:** adversarial review (correctness / security / honest-stats / design-fidelity) + live smoke (dark + light, 0 console errors).
## 6. Key risks
- **Don't recreate the 5 backing apps / routes / backends for screens 06·07·08·12·16** — they are shipped and working; PR6b is reskin + feature-fill, not rebuild (CLAUDE.md §3.3/§8). The biggest risk is mistaking a PARTIAL app for net-new.
- **No-fabrication contract (the recurring trap):** Benchmark numbers (D3), Evolution "judged by 3 models" badge (D13), Storage provenance column (D11), Profile facts (D20), Habit streak/depth (D17/D18) are **six** places PR6 could invent data. Gate each off when the real value is absent — carry the PR3/PR3.5 streak-gate precedent. A real 0 is honest; a fabricated count is not.
- **Power-surfaces route consolidation (D8)** is a deep-link landmine — 4 routes are wired into ⌘K + dock + onboarding. Reskin to shared primitives, do **not** collapse the routes in PR6.
- **All-workspaces redirect replacement (`App.tsx:66`)** changes router behavior — verify nothing else relies on `/workspaces``/home` (it does NOT per recon; switcher opens a picker modal). Add the empty-state to avoid a zero-workspace dead-end.
- **Habit streak is backend-blocked (D17)** — the flip is one line but the streak calculation owner is unconfirmed. Don't flip until the field is real, or the gate ships a fabricated streak.
- **404 ownership (D5)** — keep `NotFound.tsx` the router fallback; a Platform tab can't catch bad URLs. Don't merge them.
- **Server routes via tsx** (D3-class cheap routes, if any land for Profile facts/file-tree provenance) are not typechecked by `npm run build` → explicit `tsc -p packages/server`.
## 7. Verification gates (per sub-PR + final)
`tsc -p apps/web/tsconfig.app.json` 0 · `tsc -p packages/server` 0 (only if any cheap route lands) · FE vitest green · `npm run lint` (no new errors) · live smoke per sub-PR (dark + light, 0 console errors):
- **PR6a:** Benchmarks renders both views w/ honey-highlight + accurate numbers; Platform 3 tabs + titlebar toggle; custom 404 on a bad URL; both reachable via ⌘K.
- **PR6b:** Launcher A/B toggle; Storage A/B toggle + provenance col (or honest "—"); Usage per-model bars + budget; Vault Renew; Approvals color badges; Evolution version ladder; sextet reskinned + Room two-col + Artifacts grid + Profile facts.
- **PR6c:** `/workspaces` renders the shelf (grid + search + pills + actions + empty state) reachable via ⌘K; streak chip shows only with a real field.
## 8. Honesty log — every drift the recon found (verified vs live)
- **04:** `BUILD-PLAN.md §9.7` makes **Home the workspace selector** (Home `RecentWorkspacesPanel` shows ~2 cards, `HomeCockpit.tsx:166-226`), so §04's "reachable from rail" is **not literally true** — no rail entry exists (5-item spine locked) and the switcher pill opens a **picker modal, not the full shelf**. PR6c adds a ⌘K entry, not a rail entry. Design shows 8 sample workspaces; live has no dedicated view at all.
- **06:** `launcher.html` has BOTH variations (segmented control `:110-113`); the React `LauncherApp` renders **only Variation A** — no toggle, no Variation B. Copy is generic ("Tool Launcher", "Optional prompt") vs the spec narrative.
- **07:** The existing `FilesAppTabs` storage-type tabs (Virtual/Local/Team) are **orthogonal to** the A/B variation toggle — the recon's key correction is they must **coexist, not conflate** (storage-type filters live *inside* Variation B). The file table has **no Source/provenance column** (`:539-544` = Name/Size/Modified only). Variation A has no component, route, or on-disk tree at all.
- **08:** Design assumes a **unified ⌘K shell with 5 tabs via a left-rail switcher**; live has **5 separate full-canvas apps at separate routes** — an architectural split, not a styling gap. `VaultApp`'s Connectors tab was **moved out** to a separate `ConnectorsApp` (`:229` comment). `CapabilitiesApp.Tools` shows **generic agent tools** (read_file/bash/web_search), **not connected integrations** (those live in `ConnectorsApp`) — the design's "Tools" tab and the live "Tools" tab are different things. No Renew, no per-model bars, no budget UI, no color-stratified risk badges. Each app uses **distinct styling** (the "ONE component set" intent is unmet).
- **12:** Implementation tracks **individual runs**, design shows a **single skill card with 3 versions** — needs `(target_kind, target_name)` aggregation. Backend does **not** store "GEPA optimized · judged by 3 models" provenance (must infer or omit, D13). Variation B (diffusion) has **zero FE integration** despite waggle-dance carrying `skill_share`/`model_recipe` subtypes — it's a net-new feature, not a reskin.
- **15:** `SHOW_STREAK=false` + `STREAK_DAYS=0` are **intentional gates** awaiting a backend `streak` field (review must read them as TODO, not bugs). `OvernightHero`+`RunChip` are **already correct and live** (no work). `NotificationInbox` exists but is **isolated** (not wired to Home; renders ALL notification types uniformly — the opposite of the "earned nudge" the design demands). Memory-depth has **zero backend** (`HomeBriefing` has no `memoryCount`/`depthRank`). Correctly has no standalone `/habit` route (matches spec).
- **16:** Room = **single-column tile grid**, design wants **two-col** (shared turn stage + participants panel). Artifacts = **vertical list**, design wants **3-col card grid** with ext tiles + provenance. Profile = editable tabs only, **missing the "what Waggle knows" read-only facts** section. Mission Control = fleet/team/activity tabs, design leads with a **local-services list**. Agents swarm CTA exists but is **soft-routed/buried** (`:158`). Timeline is **fully aligned** (no work).
- **17:** **Net-new** — verified zero "benchmark" grep hits in `apps/web/src`; absent from AppId/routes/⌘K/`App.tsx`. Matrix data is hardcoded JS in `benchmark.html:168-180` (must inline or externalize). Numbers are June-2026-dated and must be **manually refreshed** from the `hive-mind` benchmark — they are accurate today (87.66 SOTA matches the published result + MEMORY.md SOTA index).
- **18:** **Net-new** — no `PlatformApp`/`PlatformRoute`; absent from AppId/routes/⌘K/`App.tsx`. The live 404 is the **generic `NotFound.tsx:1-24`** (no honey hex, no spec copy, no ⌘K affordance). The §18 Boot "warming the hive" view risks **colliding with the real AppShell boot gate** — keep it a static showcase (D6), don't rewire runtime.
- **Cross-cutting:** the BUILD-PLAN §6 PR6 row lists all 9 screens as one PR; this plan reframes that into the §2.5 split. Dock-tier `color` fields and the older apps still carry pre-warm tier colors (`text-violet-400`, `text-emerald-400`, `text-sky-400`) — swept per-file under D21, not as a separate migration.

View File

@@ -0,0 +1,173 @@
# Warm-Hive PR7 — Auth (Clerk, themed · screen 13) · Billing (Stripe, themed · screen 14) — Build Plan
> Source design: `docs/design_handoff_waggle_app/SCREENS.md §13` (Auth) + `§14` (Billing) + `design-files/screens/{auth,billing}.html`. Roadmap: `BUILD-PLAN.md §6` (PR7 = screens 13·14). Strategic gate: `DESIGN_POV.md §4` (BYO-vs-metered) + `BUILD-PLAN.md §7.5`. Branch: TBD off main @ `3764bc13` (PR1PR6 all shipped). Status: **FOUNDER-RATIFIED 2026-06-18 — D1=Option A (BYO-key + flat subscription); scope=PR7a Billing first; Clerk=optional sign-in (b), key forthcoming. Building PR7a now; PR7b deferred to next arc (needs Clerk key).** D5D17 proceed as recommended.
> Recon: 7-reader grounded recon (`pr7-recon/01..07`), every claim verified vs live code with file:line. Corrections + drift in §8 (honesty log).
> **The single most important truth in this plan:** unlike PR3PR6, PR7 renders *identity and money*. Real auth and real payments **cannot be faked** — every fabrication trap (a logged-in user that isn't real, invented invoices, hardcoded payment methods, fake "next charge" dates, a custom card form that doesn't tokenize) is gated off in §5. **PR7 is the highest fabrication-risk PR of the redesign.**
---
## 0. STRATEGIC GATE — BYO-key vs Waggle-metered (settle this FIRST, before any feature code)
`DESIGN_POV §4` (`DESIGN_POV.md:62-70`) flagged "who pays for inference (BYO-key vs Waggle-metered)" as the decision that "quietly reshapes Billing, Onboarding, and Usage" and "must be settled before Billing goes live" (`DESIGN_POV.md:89-90`; `BUILD-PLAN.md:165-166`). It was deferred through PR5 (where D1 ratified BYO-key for the model gate) and now becomes the literal precondition for PR7.
**The de-facto current commitment is Option A — and it is already shipped, wired, and tested end-to-end:**
- **BYO-key is the only inference-payment path that exists.** The shared `ModelGate` copy is literally *"Bring your own key — it's stored encrypted in your Vault and never leaves your machine"* (`ModelGate.tsx:186`), live-validated then written to Vault (`ModelGate.tsx:94-99`); onboarding hard-gates Continue on a working model (`ModelGateStep.tsx:49`). Waggle never holds an inference key or pays a provider on the user's behalf in any shipped path.
- **Stripe is flat `mode:'subscription'`** on both the local sidecar (`checkout.ts:40`, `line_items` qty 1, fixed price, PRO/TEAMS only) and `apps/www` (`checkout/route.ts:170`). Two products (Pro $19, Teams $49/seat), monthly+annual.
- **There is ZERO inference-metering plumbing.** No `createUsageRecord` / `billing_meter` / `reportUsage` / credits ledger anywhere in `packages/server/src` (grep → 0; only a code comment at `local/index.ts:1980`). The only "usage" surface is `TelemetryApp` ("Usage & cost") reading the **estimate-only** `/api/cost/summary` with an **advisory** 80%/exceeded budget warning that **enforces nothing** (`cost.ts:162-169`) — a dashboard, not a quota.
| | **Option A — Ratify BYO-key + flat subscription** | **Option B — Pivot to Waggle-metered** |
|---|---|---|
| Build cost | **Near-zero new backend.** PR7 Billing = theming over the working subscription flow + minor wiring (annual toggle, deep-link, success route). | **Multi-month strategic arc.** New per-request metering, durable usage ledger, hard quota enforcement on the inference/agent-loop path, Stripe metered prices + new webhook events, a credits/balance surface, a managed key pool (the `managedModelPool` flag has no inference-path impl today). |
| Positioning | Consistent with the shipped local-first "your key never leaves your machine" promise. | **Contradicts** that promise; adds inference COGS + abuse surface; inverts the onboarding model gate. |
| Blast radius | PR7 theming only. | Billing + Onboarding (gate inverts) + Usage (full `TelemetryApp` rebuild) + core inference path + Stripe webhook + product copy. |
**Recommendation: ratify Option A.** The codebase already committed end-to-end; Option B is a product pivot PR7 must not silently absorb. The Plans copy "Memory is free forever. You only pay for scale — no feature-count games." (`billing.html:130-131`) *leans* metered but is honestly satisfiable under A (Pro/Teams unlock scale: workspaces, marketplace, sync). **This is a founder strategic call, not a build choice — it must be answered before §3 / any code.**
---
## 1. The design contract
- **§13 Auth** (`auth.html`): split grid (1.05fr brand panel / 1fr form, brand panel hidden <820px, `auth.html:20-22,73,89-152`). **4 states***Sign in* (Google+Apple SSO + email/pw + "Forgot?"), *Sign up* (honey local-note "You don't need this to start." + Name/Email/Password "At least 10 characters"), *Verify* (6-box OTP, auto-advance + backspace-to-prev, `auth.html:163-169`), *SSO/enterprise* (work-email field + neutral "SAML, SCIM provisioning, audit logs available on Teams and KVARK · Talk to sales →"). Load-bearing trust copy: *"An account is optional — Waggle runs fully local without one"* + *"Your memory stays yours; sign-in only adds sync"* (`auth.html:98-99`). Design note `SCREENS.md:277`: "Build with **Clerk** components themed to the tokens." The top `.controls` segmented switcher (`auth.html:78-87`) is **concept-harness scaffolding for previewing states — NOT product UI**.
- **§14 Billing** (`billing.html`): segmented **Plans / Checkout / Success / Manage** (`billing.html:117-122`). Plans: H1 "Upgrade your hive.", Monthly|Annual 20% toggle, 3 cards (Solo $0 "Current" disabled · Pro $19/mo honey "Most popular" → "Choose Pro" · Teams $49/seat → "Choose Teams"). Checkout: a *mock* in-app card form + order summary. Success: "You're Pro." + a receipt block. Manage: current plan + payment method + invoices (Paid+PDF) + change/cancel. Governing instruction `SCREENS.md:294`: **"Use Stripe Checkout/Customer Portal where possible; theme to tokens."** The card form and invoice table are **fidelity mockups of Stripe's hosted surfaces, not a spec to hand-build.**
---
## 2. Current state — **grounded, the two-stack split is the master fact** (verified vs live, 2026-06-18)
**THE master fact (read before anything):** there are **two** server stacks and PR7 builds in `apps/web` → the **LOCAL sidecar** (`packages/server/src/local/index.ts`), which is **config.json-tier and has NO Clerk auth**. The rich Clerk-linked Stripe flow + real Clerk UI live **only** in `apps/www` (Next.js, PR8) + the cloud entry `packages/server/src/index.ts`. **Do not conflate them.** (`recon 01 §0`, `recon 02 §0`, `recon 05 §1-3`, `recon 03 §0`.)
| Layer | Reality today (file:line) | State |
|---|---|---|
| **Local "auth"** | Per-process random bearer token `wsSessionToken = crypto.randomBytes(32)` minted at sidecar boot (`local/index.ts:1409`), served auth-exempt+same-origin via `GET /api/auth/session-token` (`local/index.ts:2047-2051`), enforced by **token equality, no user lookup** (`security-middleware.ts:340-377`). Adapter attaches `Authorization: Bearer` + 401→refresh→retry for process rotation (`adapter.ts:298-337,444-470`). **This IS the design's "account is optional, runs fully local."** | **REAL (not a login)** |
| **Clerk in `apps/web`** | **ZERO.** grep `@clerk` in `apps/web/package.json` → none; `ClerkProvider`/`SignIn`/`useSignIn` across `apps/web/src` → 0 files. No `/auth` route. Clean slate. | **ABSENT** |
| **Clerk elsewhere (REAL, not PR7's surface)** | Cloud server verifies Clerk JWTs (`plugins/auth.ts:3,21,31-48` verifyToken→`users` table keyed by `clerkId`); `apps/www` has full themed Clerk UI (`<SignIn/>` catch-all, `ClerkProvider` + Hive appearance map, `apps/www/app/layout.tsx:6-93`). Local sidecar references Clerk only as `isTeamMode = !!process.env.CLERK_SECRET_KEY` to toggle a session timeout (`security-middleware.ts:307-309`). | **REAL (cloud/www only)** |
| **Desktop "identity"** | Free-text **name** from IdentityLayer (onboarding), surfaced as `HomeBriefing.userName` (`home.ts:261-270`) + sidebar row via `adapter.getIdentity()` degrading to "Account"/"W" when blank (`AppShell.tsx:98-104,321`). **Identity, NOT auth** — must never render as "signed in." | **REAL (identity ≠ account)** |
| **Stripe backend (local sidecar)** | `stripeRoutes` registered (`local/index.ts:128,2161`); all gate on `STRIPE_SECRET_KEY`**503 `STRIPE_NOT_CONFIGURED`** when absent (`stripe/index.ts:25-43`). `create-checkout-session` REAL (hosted Checkout, `mode:subscription`, PRO/TEAMS, period-aware, returns `{url}`, `checkout.ts:16`). `create-portal-session` REAL (hosted Customer Portal, `requireTier('PRO')`, needs `config.json.stripe_customer_id` else 400 `NO_STRIPE_CUSTOMER`, `portal.ts:15,32`). `sync` REAL + payment-gated (`sync.ts:46-49`), returns only `{tier,customerId}` (`sync.ts:83`). Webhook **flips tiers** (payment-gated, signature-verified, idempotent, TOCTOU-serialized; 17/17 tests green per CLAUDE.md §10 E-10; `webhook.ts:114-158`). | **REAL** |
| **No invoice / payment-method / sub-detail routes** | grep `invoice`/`paymentMethod`/`invoices.list`/`paymentMethods` in `packages/server/src`**0 product hits**. | **MUST-BUILD or defer to Portal** |
| **Billing FE** | Fully wired: `adapter.createCheckoutSession/createPortalSession/syncStripeCheckout/getTier` (`adapter.ts:2658-2685`) + `useBilling` hook orchestrates the happy path incl. `?session_id=` auto-sync (`useBilling.ts:104-115`) + the `tierResolved` honesty guard (`useBilling.ts:16-46`). Settings → **"Plan" tab** is already the honest Manage surface: real tier badge, upgrade buttons → `startCheckout`, "Manage Subscription" → `openPortal` (`SettingsApp.tsx:511-655`, `:621-635`). | **REAL** |
| **Routing** | All 28 screens are children of one `<Route path="/" element={<AppShell/>}>` (`App.tsx:52-110`). No `/auth`, `/billing`, `/payment-success` routes (grep: 0). `SettingsApp` inits `activeTab='models'` with **no `?tab=` reader** (`SettingsApp.tsx:53`) → `/settings?tab=billing` silently opens Models. Breadcrumb label sourced only from `dock-tiers` via `matchNavRoute` (`AppShell.tsx:224-229`). | mixed |
| **Price env contract** | Dual: 4-var (`STRIPE_PRICE_PRO_MONTHLY/_ANNUAL/_TEAMS_*`) + legacy single-var, resolved by `tierFromPriceId`/`priceIdForTier` (`stripe/index.ts:71-100`). Live+test Stripe accounts hold the prices per CLAUDE.md §10 M7. | **REAL (EXTERNAL-DEP at runtime)** |
**Net:** **Screen 14 (Billing) is ~90% real** — a themed re-skin of the existing `useBilling`→Stripe-hosted flow + four small builds (annual toggle wiring, `?tab=` deep-link, `/payment-success` route, gated Success/Manage detail). **Screen 13 (Auth) is overwhelmingly net-new + EXTERNAL-DEP + decision-gated** — no Clerk SDK, no login UI, no real user session anywhere the desktop can reach.
### 2.5 Recommended sub-PR split (by risk shape — the core structural recommendation)
Auth and Billing have **opposite risk shapes**: Billing is reskin-over-real with a localized blast radius and no new dependency; Auth is a net-new pre-shell route + an EXTERNAL Clerk-key dependency + an unproven Tauri-WebView OAuth question. Coupling them in one PR would block the de-risked Billing work behind the founder's Clerk-architecture decision and key provisioning. **Recommend a 2-way split, ordered by value/risk:**
| Sub-PR | Screen | Scope theme | Why this grouping | Effort |
|---|---|---|---|---|
| **PR7a — Billing themed over existing Stripe** | **14 Billing** | Reskin the Settings → Plan tab (and/or a standalone `/billing`) to the §14 4-state layout over the *already-real* `useBilling`→hosted-Checkout/Portal flow. Adds: annual-toggle wiring, `?tab=` deep-link, `/payment-success` route. **No new dependency.** | **Lower risk, ships standalone, gated only on the §0 BYO/metered call** (which is a copy decision under Option A, not a code blocker — the flow supports either). Hosted Checkout/Portal means **zero fabricated billing data**. | **SM** |
| **PR7b — Auth (Clerk) pre-shell route** | **13 Auth** | New top-level **sibling route outside AppShell** + (if Option b) `@clerk/clerk-react` provider themed to warm tokens, with a hard **no-key → local-first accountless** fallback. | **Higher risk + EXTERNAL-DEP-blocked** (Clerk publishable key) + an architectural decision (cloud-only vs optional-sign-in vs full-gate) + an unproven Tauri-WebView OAuth spike. Isolating it keeps the Clerk dependency + the pre-shell routing risk off Billing. | **ML** (or **S** if cloud-only/handoff) |
**Sequencing:** PR7a first (de-risked, no dependency, near-zero new backend, ships the upgrade funnel). PR7b second (carries the Clerk dependency + the architecture call). Each is independently revertible. **If the founder defers the Clerk-architecture decision or the key, PR7a still ships alone** — Billing is not blocked by Auth.
---
## 3. Decisions — **NEED FOUNDER RATIFICATION before feature code** (recommend-and-proceed unless you object; D1/D2/D5 are strategic)
| # | Decision | Recommended | Why |
|---|---|---|---|
| **D1** | **§0 BYO-key vs Waggle-metered** (the strategic gate) | **Ratify Option A — BYO-key + flat subscription** | The codebase already committed end-to-end (`ModelGate.tsx:186`, `checkout.ts:40`, zero metering plumbing). Option B is a multi-month pivot that contradicts the shipped "your key never leaves your machine" promise. Founder strategic call; everything else assumes A. |
| **D2** | **Clerk architecture for the desktop** (cloud-only vs optional-sign-in vs full-gate) | **Option (b): optional Clerk in the SPA, accountless default; sign-in unlocks sync/Teams/billing. REJECT full-gate.** | The only option consistent with the design copy ("account is optional", `auth.html:98`), the accountless sidecar (`local/index.ts:2047-2052`, `settings.ts:309-331`), the existing `useBilling` tier flow, and the prior ratified "Tauri Clerk = Phase 2 fast-follow, NOT Day 0" (brief 2026-05-03:225). Full-gate (c) breaks accountless boot — do not build. Founder strategic call (governs the whole-SPA provider wrapping in `main.tsx`). |
| **D3** | **Sub-PR split** (§2.5) | **Ship the 2-way split: PR7a (Billing) → PR7b (Auth)** | Opposite risk shapes; coupling blocks de-risked Billing behind the Clerk decision + key. Billing has no new dependency; Auth is EXTERNAL-DEP + architecture-gated. Independently revertible. Founder structural call. |
| **D4** | **Auth surface scope** — browser/cloud-first vs in-WebView Tauri sign-in now | **Browser/cloud-first; treat in-WebView Tauri sign-in as an explicit later deep-link/OAuth spike** | Clerk hosted OAuth/redirect inside a Tauri WebView is **unproven** and the prior brief deferred it (brief:225); the `apps/www` browser pattern is proven. Determines whether PR7b must also solve Tauri deep-link OAuth (large, risky) or reuse the proven browser provider (small). |
| **D5** | **Checkout UI** — custom in-app card form vs hosted Stripe Checkout | **Hosted Stripe Checkout redirect (reuse `createCheckoutSession`); theme the "Checkout" segment as an order-summary that hands off** | `SCREENS.md:294` mandates "use Stripe Checkout where possible"; the backend only emits a hosted URL (`checkout.ts:39-51`); `apps/web` has **zero** Stripe.js/PaymentElement (grep 0). A real PAN field raises PCI scope SAQ-A → SAQ-A-EP for zero functional gain. The `billing.html` card form is a mockup of Stripe's page, not a spec. **Both a fabrication trap and a PCI trap — do not hand-build.** |
| **D6** | **Manage state** — in-app invoice/payment-method/cancel vs hosted Customer Portal | **Hosted Customer Portal launchpad (reuse `createPortalSession`); themed current-plan header in-app, all mutations + invoices + payment method via Portal** | Portal natively covers payment-method update, invoice PDFs, plan change, cancel. No invoice/payment-method/sub-detail route exists (grep 0). `SettingsApp.tsx:621-635` already does exactly this. Building in-app Stripe write-APIs is scope+fabrication risk we don't need. (If the founder *explicitly* wants in-app rendering: build new sidecar routes against `stripe.invoices.list`/`paymentMethods.list` and render **strictly from live data** — never placeholders.) |
| **D7** | **Billing registration** — standalone `/billing` vs Settings "Plan" tab | **Keep the Settings → Plan tab as primary (it's fully wired to real Stripe); reskin it to the §14 layout + add a `?tab=` reader. Add a standalone `/billing` (PR6 wrapper pattern, AppShell child) only if design wants the full-screen funnel outside Settings chrome — reusing `useBilling` verbatim** | The Plan tab is real and tier-honest today. A `?tab=billing` deep-link inherits the "Settings" breadcrumb automatically (`dock-tiers.ts:108`). Standalone is optional and must add its own `dock-tiers` route+label entry (don't repeat PR6's label-less surfaces). |
| **D8** | **Monthly/Annual 20% toggle** — honor at checkout vs display-only | **Build it: thread `billingPeriod` through `adapter.createCheckoutSession` + `useBilling.startCheckout`** | Backend `priceIdForTier(tier, 'annual')` already resolves the annual price (`stripe/index.ts:91-100`) but the adapter drops the arg (`adapter.ts:2667`), so it always charges monthly. A toggle that visibly changes price but charges monthly is a **trust/billing bug**. Small FE change; needs `STRIPE_PRICE_*_ANNUAL` envs set (EXTERNAL-DEP). |
| **D9** | **Success state** — render the receipt block vs confirmation-only | **Confirmation off the synced tier only ("You're Pro"); GATE OFF the receipt rows; drive any trial line from real `trialDaysRemaining`** | `sync` returns only `{tier,customerId}` (`sync.ts:83`) — the receipt #, "Trial ends Jun 28", "Then $19", "Emailed →" have **no data source** and would be fabricated. The post-redirect `/payment-success` URL is real (`checkout.ts:42`) but needs a route (D11). |
| **D10** | **Plan-card prices** — hardcode copy vs machine-readable source | **Hardcode the display copy ($0/$19/$49-seat, $15/$39 annual) from `tiers.ts:7-12` in ONE constant, treat as copy not fact; never invent per-seat math beyond what `tiers.ts` documents** | Only `stripePriceId` is a real field in `TIER_CAPABILITIES`; the dollar amounts are doc-comment copy. The *charged* amount/tax must come from Stripe's hosted page, never asserted by our UI. Prices match `billing.html` exactly today. |
| **D11** | **`/payment-success` (+ `/payment-cancelled`) route** | **Add `/payment-success` as an AppShell child (user is back inside the app post-checkout) using the PR6 wrapper pattern; reuse `useBilling`'s `?session_id=` auto-sync** | `checkout.ts:42` sets `success_url=/payment-success?session_id=…` but no such route exists (grep 0); today `?session_id=` is only read on whatever page is mounted. Small MUST-BUILD. |
| **D12** | **`?tab=` deep-link reader in `SettingsApp`** | **Add a small `?tab=` initializer (read once on mount, snap `activeTab`); retarget ⌘K "Upgrade to Pro"/"Settings" + `UpgradeModal`/`TrialExpiredModal` fallbacks to `/settings?tab=billing`** | `SettingsApp` hardcodes `activeTab='models'` with no reader (`SettingsApp.tsx:53`); the existing `routes.ts:52` backup deep-link is already broken the same way. Without it every upgrade entry lands on Models, not Plan. |
| **D13** | **OTP fidelity** (PR7b) — themed prebuilt Clerk vs bespoke 6-box widget | **Themed prebuilt `<SignIn/>`/`<SignUp/>` for v1 (accept Clerk's built-in code step); upgrade to `useSignIn` custom flow only if review demands the exact 48×56 honey OTP boxes** | `SCREENS.md:277` literally says "Build with Clerk components themed to the tokens." Prebuilt = small; custom flow = a whole hand-built sign-in/verify state machine with its own error handling + a11y. |
| **D14** | **Clerk appearance** (PR7b) — shadcn theme vs full manual map | **shadcn theme (`@clerk/themes` shadcn) auto-reading the warm shadcn vars PR1 repointed (`index.css:20,29,46`) + a thin `variables` override for honey + Hanken; copy the `as const` gotcha avoidance from `apps/www/layout.tsx:31-34`** | `components.json` exists, so theming is mostly automatic + token-driven. A full manual hex map duplicates ~60 lines/component and drifts from the token system. Copy the *pattern*, not the *cooler `apps/www` hex*. |
| **D15** | **SSO/enterprise panel** (PR7b) — live SAML vs sales CTA | **Keep it a custom "Talk to sales → Teams/KVARK" CTA; do NOT implement live SAML/SCIM** | The design panel (`auth.html:148`) is a sales CTA, not a live form; SAML/SCIM/audit are Teams/KVARK/Clerk-Enterprise features. Matches the KVARK funnel. |
| **D16** | **EXTERNAL-DEP provisioning** | **Founder provides `VITE_CLERK_PUBLISHABLE_KEY` (reuse the existing `apps/www` instance key) + confirms Google/Apple social connections in the Clerk dashboard; confirms the sidecar runtime env carries `STRIPE_SECRET_KEY` + the 4 price IDs + `STRIPE_WEBHOOK_SECRET`** | Without the Clerk key, PR7b degrades to the accountless local-first state (honest). Without the Stripe envs, all `/api/stripe/*` return 503 and Billing must render an honest "not configured" disabled state. See §6. |
| **D17** | **Warm-token sweep** | **In every sub-PR, sweep the touched files** — Auth/Billing must use `--honey`/`--surface`/`--line-strong`/`--r` from `waggle.css` (already landed by PR1, `BUILD-PLAN §3.1`); honey button-fg `#1a1407` = PR1's `--primary-foreground` | Both screens are heavily token-dependent; one pass keeps the "single honey accent" honest. No new tokens needed. |
---
## 4. Architecture
### PR7a (Billing — reskin over REAL Stripe; DO NOT recreate `useBilling`, the adapter Stripe layer, or the server `stripe/*` routes)
- **Reskin** `SettingsApp.tsx:511-655` (Plan tab) to the §14 segmented **Plans / Checkout / Success / Manage** layout, warm tokens (D17). Reuse the existing tier card + `useBilling` + `tierResolved` honesty guard verbatim.
- **Plans:** 3 cards from `tiers.ts:7-12` copy (D10) + Monthly/Annual toggle (D8); current-plan marker from `useBilling().tier` honoring `tierResolved`; "Choose Pro/Teams" → `startCheckout(tier, period)`.
- **Checkout segment:** themed order-summary that redirects to **hosted Stripe Checkout** (D5) — no in-app PAN field.
- **Success:** confirmation off synced tier (D9); receipt block gated off.
- **Manage:** themed current-plan header + single "Manage via Stripe" → **hosted Customer Portal** (D6); no in-app invoice/payment-method rows.
- **Wiring (the small builds):** thread `billingPeriod` through `adapter.createCheckoutSession` (`adapter.ts:2667`) + `useBilling.startCheckout` (D8); add `?tab=` reader to `SettingsApp` (D12); add `/payment-success` AppShell child route via the PR6 wrapper pattern (D11); retarget ⌘K + modal fallbacks (D12). **Optional** standalone `/billing` (D7) follows the PR6 `BenchmarkRoute.tsx:1-11` pattern (AppShell child).
- **Backend:** none under Option A (D1) + hosted Checkout/Portal (D5/D6). Only if the founder picks in-app Manage rendering do new `invoices.list`/`paymentMethods.list` sidecar routes land → `tsc -p packages/server`.
### PR7b (Auth — net-new pre-shell route + optional Clerk; the `clerk-setup`/`clerk-react-patterns`/`clerk-custom-ui`/`clerk-billing` skills apply)
- **`/auth` is the ONE pre-shell route** — register as a **top-level sibling OUTSIDE `<Route path="/" element={<AppShell/>}>`** (`App.tsx:52`), no Sidebar/StatusBar/ChatHost/boot gate. `ThemeProvider` already wraps `<BrowserRouter>` (`App.tsx:53`) so warm tokens apply outside the shell. Use a plain `AppErrorBoundary` (`onClose → window.location.reload()`), **NOT `SurfaceBoundary`** (its `onClose → /home` assumes an authenticated home, `SurfaceBoundary.tsx:13`). No ⌘K entry (pre-login). No breadcrumb needed (renders outside AppShell, so StatusBar never mounts).
- **Custom layout (MUST-BUILD, no backend):** split brand panel + pitch + trust lines + local-first banner + enterprise→sales CTA (D15). All copy verbatim in `recon 03 §2/§4`.
- **Clerk (Option b, D2):** add `@clerk/clerk-react` + `@clerk/themes`; wrap `<App/>` in `main.tsx:18` with a **no-key guard** — if `import.meta.env.VITE_CLERK_PUBLISHABLE_KEY` is undefined, render children WITHOUT `ClerkProvider` and show the accountless local-first state (never crash, never fabricate a user). Themed via the shadcn appearance approach (D14). Prebuilt `<SignIn/>`/`<SignUp/>` themed (D13); `<UserButton/>`/`useUser()` may re-feed the sidebar `userName` row (currently `null`→"Account").
- **`getToken()` seam:** `useAuth().getToken()` returns the Clerk JWT in exactly the `Bearer` shape the adapter (`adapter.ts:445`) + cloud `plugins/auth.ts:31` already consume — an *optional* sign-in can swap the device token for a user JWT **only when the user opts into cloud/Teams**, leaving the accountless local path untouched. Do NOT make Clerk the API authorizer on the local path (regresses local-first).
---
## 5. No-fabrication contract — every place PR7 could invent identity or money (gated off)
PR7 renders identity and money. Each item below has **no honest data source today** and **must be gated off, never invented** (carry the PR3/PR3.5 `tierResolved`/`userName=null` precedent):
| # | Fabrication trap | Why it's fake | Gate (the honest behavior) |
|---|---|---|---|
| **F1** | **A logged-in identity that isn't real** (name/email/avatar, "Signed in as…") | The desktop has no Clerk session; "identity" is a memory-derived IdentityLayer name (`AppShell.tsx:98-104`). | With no `VITE_CLERK_PUBLISHABLE_KEY`, render the **accountless local-first** state; the sidebar `userName=null`→"Account"/"W" fallback is the accepted honest UI (BUILD-PLAN §9). Only populate from a **real** `useUser()`. |
| **F2** | **Fake invoices / PDF receipts** (Manage: "Invoice · Paid · PDF", `billing.html`/`SCREENS.md:292`) | No invoice route exists (grep 0). | Route to the hosted **Customer Portal** (`openPortal`); never render invented invoice rows or fake PDF links. No customer → empty/portal state, not stub rows. |
| **F3** | **Fake payment method ("VISA ···4242")** (`SCREENS.md:291`) | The `4242` is literally Stripe's **test PAN**, design filler; no payment-method route. | Render only from live `stripe.paymentMethods.list` (if D6 in-app path) else **Portal-only**; never hardcode a masked card. |
| **F4** | **Fake "next charge" / renewal / billing-cycle date** ("renews Jul 14", `SCREENS.md:291-292`) | Not returned by `sync` (`sync.ts:83`) or `getTier`. | Derive from a real subscription fetch or **omit**; trial dates only via real `trialDaysRemaining`/`trialStartedAt`. |
| **F5** | **Itemized Success receipt** ("$19 charged", receipt #, "Emailed →", `billing.html`) | `sync` returns only `{tier,customerId}`. | Generic "You're Pro" confirmation off the synced tier; receipt rows gated off (D9). |
| **F6** | **Custom in-app card form** (email/card 4242/expiry/CVC) | Backend only emits a hosted URL; no Stripe.js in `apps/web` (grep 0). A real-looking PAN that doesn't tokenize is fake AND a PCI-scope trap (SAQ-A → SAQ-A-EP). | **Never collect raw PAN in-app**; redirect to hosted Stripe Checkout (D5). |
| **F7** | **Tier shown as FREE before resolution** | `useBilling` defaults to `'FREE'` as a placeholder, not a fact. | Honor `tierResolved` (`useBilling.ts:16-46`) — render the unresolved state, not the FREE upgrade grid, until a real `getTier()` round-trip succeeds. |
| **F8** | **A working checkout when Stripe is unconfigured** | All `/api/stripe/*` 503 without `STRIPE_SECRET_KEY` (`index.ts:25-43`). | Render an honest "not configured" disabled state, not a clickable fake "Subscribe". |
| **F9** | **Fake annual price** (client-side `$19 × 0.8`) | The charged amount must match Stripe. | The 20% toggle must resolve through the real annual price var (`priceIdForTier(tier,'annual')`, D8), not a cosmetic client number; the hosted page renders the true amount/tax. |
| **F10** | **Fake SSO success** ("Signed in with Google") + **fake OTP verification** | The `auth.html` demo advances on any input (`auth.html:138,164`); SSO buttons "succeed" with no provider. | SSO/OTP must perform a **real Clerk redirect/verification**; with no Clerk wired, disable / route to `apps/www` / show the local-first path — never simulate a session. |
| **F11** | **SAML/SCIM rendered as a live form** | Enterprise panel names SAML/SCIM/audit (`auth.html:148`) — Teams/KVARK/Clerk-Enterprise features. | Keep a **sales CTA** (D15); do not render a SAML form that does nothing. |
| **F12** | **Usage/credits balance** ("$N remaining", "X of Y tokens") | Only relevant if Option B; no ledger exists, `/api/cost/summary` is **estimate-only** + **advisory** (`cost.ts:162-169` enforces nothing). | Under Option A this surface isn't built; the estimate dashboard must not be dressed up as a metered balance or imply requests are capped. |
| **F13** | **Hardcoded demo identity** (`mara@egzakta.com` / "Mara Kovač", `auth.html:113,127,136`; `billing.html` name-on-card) | Pure mock placeholders. | Never pre-fill or display the demo user; no real account email exists pre-Clerk. |
---
## 6. External dependencies — founder must supply (EXTERNAL-DEP blockers)
| Dep | Needed by | Blocker behavior if absent (the honest fallback) |
|---|---|---|
| **`VITE_CLERK_PUBLISHABLE_KEY`** (reuse existing `apps/www` instance key) | PR7b Clerk SPA sign-in | No key → `/auth` degrades to the **accountless local-first** state ("continue without an account → Home"); never a fake identity (F1/F10). Desktop default path needs **no key**. |
| **Google/Apple social connections** enabled in the Clerk dashboard | PR7b SSO buttons | Absent → SSO buttons disabled / "coming soon" / route to `apps/www`; never fake a session (F10). |
| **`STRIPE_SECRET_KEY` + 4 price IDs (`STRIPE_PRICE_{PRO,TEAMS}_{MONTHLY,ANNUAL}`) + `STRIPE_WEBHOOK_SECRET`** in the sidecar runtime env | PR7a checkout/portal/sync/webhook | Absent → all `/api/stripe/*` return **503 `STRIPE_NOT_CONFIGURED`**; Billing renders an honest "not configured" disabled state (F8). Live+test accounts already hold the prices (CLAUDE.md §10 M7) — the secret + IDs must be present at runtime. |
| **`config.json.stripe_customer_id`** (written only after a real paid checkout/webhook, `webhook.ts:60`) | PR7a Manage → Portal | Absent → `create-portal-session` returns **400 `NO_STRIPE_CUSTOMER`** (`portal.ts:32`); Manage must show the pre-subscription state, not a broken portal jump. |
| **§0 BYO-vs-metered decision (D1)** | Plans copy + whether any Usage/metering surface is implied | Unsettled → do not write the Plans/Usage copy that commits to metered; the flow supports either but the positioning must be chosen. |
| **Clerk architecture decision (D2)** + **Tauri-WebView OAuth spike (D4)** | PR7b shape | Unsettled → PR7b cannot start; PR7a still ships independently. |
---
## 7. Phased plan (TDD; commit per phase; FE `tsc -p apps/web/tsconfig.app.json` + vitest each)
**PR7a — Billing** (gated on D1 copy decision; no new dependency)
- **A1 — Wiring prerequisites.** `?tab=` reader in `SettingsApp` (D12); thread `billingPeriod` through adapter + `useBilling.startCheckout` (D8); add `/payment-success` AppShell child route (D11); retarget ⌘K + `UpgradeModal`/`TrialExpiredModal` fallbacks (D12). Unit tests (deep-link snaps to Plan; checkout sends period; success route auto-syncs on `?session_id=`).
- **A2 — Plan tab reskin.** §14 Plans/Checkout/Success/Manage segmented layout over `useBilling`; warm tokens (D17); 3 cards + annual toggle (D10/D8); Checkout = hosted-redirect summary (D5); Success = confirmation only, receipt gated (D9/F5); Manage = current-plan header + Portal launchpad (D6/F2/F3/F4); honor `tierResolved` (F7) + 503 "not configured" state (F8). Extend `SettingsApp` tests (renders unresolved state; gated receipt/invoice/payment-method; honey "Most popular").
- **(optional) A3 — Standalone `/billing`** (D7) only if design needs the full-screen funnel — PR6 wrapper pattern, reuse `useBilling`, add a `dock-tiers` route+label entry.
**PR7b — Auth** (gated on D2 architecture + D16 Clerk key; can defer/ship cloud-only)
- **B1 — Pre-shell `/auth` route + custom layout.** Sibling route outside AppShell (`AppErrorBoundary`, not `SurfaceBoundary`); split brand panel + pitch + trust lines + local-first banner + enterprise sales CTA (D15); all copy verbatim. Unit test (renders outside shell; no fabricated identity with no key — F1/F13).
- **B2 — Clerk provider + themed components** (Option b). `@clerk/clerk-react` + `main.tsx` wrap with no-key guard; shadcn appearance + warm override (D14); prebuilt themed `<SignIn/>`/`<SignUp/>` (D13); SSO/OTP perform real Clerk flows or degrade honestly (F10/F11). Optional `getToken()`→Bearer cloud-sync seam (do not make Clerk the local API authorizer). Tests (no-key → accountless; real `useUser()` only).
**Each sub-PR ends with:** adversarial review (correctness / security / honest-identity-and-money / design-fidelity) + live smoke (dark + light, 0 console errors).
---
## 8. Honesty log — every drift the recon found (verified vs live)
- **The two-stack split is the master correction.** PR7 builds in `apps/web` → the **local sidecar** (config.json tier, **no Clerk**). The rich Clerk-linked Stripe flow + real Clerk UI are **only** in `apps/www` + the cloud server — PR8 territory, not reachable from the desktop. Conflating them would falsely assume Clerk identity + lazy-customer billing are "already available." (`recon 01 §0`, `recon 02 §0`, `recon 03 §0`, `recon 05 §1-3`.)
- **"Auth" today is a per-process machine bearer token, not a login.** `wsSessionToken` (`local/index.ts:1409`) enforced by token equality (`security-middleware.ts:340-377`); the only "session lifecycle" is a silent 401→refresh for process rotation (`adapter.ts:316-337`). There is no signin/signup/logout anywhere in `apps/web`. `LoginBriefing.tsx` is the **overnight-work overlay**, not auth.
- **Desktop "identity" ≠ account.** IdentityLayer name (`AppShell.tsx:98-104`) is a memory record typed in onboarding — it must **never** render as "signed in" (F1).
- **Billing is ~90% real and tier-honest already.** `useBilling` + adapter + `stripe/*` all wired; the Settings Plan tab already does FREE/TRIAL→`startCheckout` and PRO/TEAMS→`openPortal` with the `tierResolved` guard. Screen 14 is a reskin, not a rebuild — the biggest risk is mistaking the real flow for net-new.
- **The `billing.html` card form + invoice table + "VISA ···4242" + receipt rows are fidelity MOCKUPS of Stripe's hosted surfaces**, not a spec. `SCREENS.md:294` says "use Stripe Checkout/Customer Portal where possible." A custom PAN form is both fabrication (F6) and a PCI-scope jump.
- **The annual toggle is a real FE build gap, not a styling gap.** Backend `priceIdForTier(tier,'annual')` resolves the annual price (`stripe/index.ts:91-100`) but the adapter drops `billingPeriod` (`adapter.ts:2667`) → the design's 20% toggle would visibly change the price label while charging monthly (a billing bug) unless wired (D8/F9).
- **`/settings?tab=billing` silently opens Models.** `SettingsApp` hardcodes `activeTab='models'` with no `?tab=` reader (`SettingsApp.tsx:53`); the existing `routes.ts:52` backup deep-link is already broken the same way. Upgrade buttons currently land on Models, not Plan (D12).
- **`/payment-success` is a dangling success_url.** `checkout.ts:42` redirects to a route that doesn't exist (grep 0); today `?session_id=` is only read on whatever page happens to be mounted (D11).
- **DESIGN_POV said the design "supports either but commits to neither" (`DESIGN_POV.md:70`) — but the IMPLEMENTATION has since committed to Option A** (BYO-key + flat subscription) end-to-end. The §0 gate is now reconciling copy with shipped reality, not choosing from scratch.
- **The `auth.html` top `.controls` segmented switcher is concept-harness scaffolding** for previewing the 4 states — NOT product UI. In the real build, state is route/Clerk-flow-driven.
- **`@clerk/react` is in `node_modules` only as a transitive dep of `apps/www`'s `@clerk/nextjs`** — NOT an `apps/web` dependency. PR7b must add `@clerk/clerk-react` explicitly. The correct skill is `clerk-react-patterns` (RR6 SPA), **NOT** `clerk-react-router-patterns` (RR7 framework mode — does not apply, `recon 05 §10`).
- **`apps/www`'s themed Clerk uses the OLD cooler Hive hex** (`#08090c`/`#e5a000`, `apps/www/layout.tsx:38-44`); copy the *pattern*, not the hex — `apps/web` is warm (`#14110b`/`#e9a52c`, `index.css:20,29`). Copy the `as const` gotcha avoidance (`layout.tsx:31-34`).
- **No `KvarkNudge` component exists in `apps/web/src`** (grep 0; CLAUDE.md §9 references it but it isn't in the web app) — the Enterprise CTA is the inline `kvark.ai` link (`SettingsApp.tsx:644-651`); the Auth enterprise panel (D15) reuses that funnel as a sales CTA.
</content>
</invoke>

View File

@@ -0,0 +1,144 @@
# Warm-Hive Redesign — PR8 Build Plan (`apps/www` landing → warm-Hive identity)
> Source design package: `docs/design_handoff_waggle_app/` — esp.
> `design-files/Waggle Landing.html` (648 lines, the canonical landing reference) +
> `design-files/styles/waggle.css` (token master) + `README.md §9` (landing copy notes).
> Roadmap slot: `BUILD-PLAN.md §6` — **PR8, the final roadmap item** (PR1PR7 all shipped to origin/main @ `58f64f22`).
> Branch: `feature/warm-hive-pr8`. Merge convention: `--no-ff` (PR5/PR6/PR7 pattern).
---
## 0. Strategic gate — scope fork (resolved by evidence, recommend-and-proceed)
PR8's slot in §6 reads "**full content + identity**." Recon resolved this:
- **Content already shipped.** `apps/www` is a complete Next.js 15 (App Router) landing with
deliberate, current copy — `app/page.tsx:24-28` documents the live structure as the chosen
**"N2 IA"** with "the committed flat copy" (hero = *"Be the expert. We'll be the AI."*).
`messages/en.json` (311 lines) holds the full marketing copy, intentionally **evolved past**
the older `Waggle Landing.html` reference (current = 5 pillars + comparison/wow beats; the
reference HTML = 6 pillars + BYO-agent + self-evolving sections). The current copy is the
newer direction and is committed.
- **Identity is stale.** `app/globals.css:4-26` still carries the **pre-redesign cool palette**
(`--hive-950:#08090c`, `--honey-500:#e5a000`, blue-grey hive greys, saturated `--status-ai
#a78bfa`/`--status-healthy #34d399`) and **Inter** as the typeface — none of the warm-Hive
identity PR1 applied to `apps/web`.
**Ratified scope (recommend-and-proceed unless founder objects):**
**PR8 = identity reskin only.** Port the warm-Hive token values + Hanken Grotesk + honey-accent
discipline + honeycomb/hex motifs onto the existing N2 landing. **Do NOT rewrite copy** to the
older reference HTML. **Do NOT touch benchmark/proof claims** (honest-stats — see §5). Treat the
reference HTML as the source of truth for *visual treatment*, `en.json` as the source of truth
for *copy*.
---
## 1. Design contract (what "warm-Hive" means for the landing)
From `waggle.css §7` (and as implemented in `apps/web` PR1):
- **Dark default** surfaces: `--bg #14110b · --surface #1f1a12` (warm graphite, replaces cool `#08090c`).
- **Honey accent**: `#e9a52c` (replaces `#e5a000`), used *sparingly* — primary buttons, active nav,
key metrics, focus rings, 12 highlight words per headline. Never decorative fills.
- **Type**: **Hanken Grotesk** (display+body, 300800; headings 600 at `-0.02..-0.03em`) +
**JetBrains Mono** (mono labels). Replaces Inter.
- **Desaturated semantics**: `--intel #b196dd` (was `#a78bfa`), `--healthy #6cb78c` (was `#34d399`).
- **Motifs**: honeycomb `.05` opacity SVG texture (warm line stroke), hexagon clip-path for the W
mark / persona tiles, warm shadows + honey glow.
- **Dark-only** for the landing (current site has no light theme; the design specifies both, but
adding a landing theme toggle is out of reskin scope → deferred, §8).
---
## 2. Current state (grounded, file:line)
| Fact | Evidence |
|---|---|
| Next.js 15.1 App Router, React 19, TS 5.9, **vanilla CSS (no Tailwind)** | `apps/www/package.json:14-24`, `globals.css:2` |
| Tokens = old cool palette + Inter | `globals.css:4-40` |
| Components style via inline `var(--hive-*)`/`var(--honey-*)` → value remap cascades | grep across `app/_components/*` |
| **Hardcoded** hex (bypasses vars) — must fix explicitly | `layout.tsx:38-91` (Clerk), `HeroVisual.tsx:62-309` (SVG), `BrandPersonasCard.tsx:343-417` (`<style>`) |
| Honeycomb SVG uses URL-encoded old hex `%231f2433` | `globals.css:54` |
| Honey rgb literal `229,160,0` in glow/selection/shadow | `globals.css:21,22,25,48` |
| Clerk + Stripe + next-intl wired; legal routes + sign-in/up + account exist | `layout.tsx`, `app/api/stripe/*`, `app/(legal)/*` |
| Single FE test (`BrandPersonasCard.test.tsx`); vitest | `__tests__/`, `vitest.config.ts` |
---
## 3. Decisions (recommend-and-proceed)
| # | Decision | Choice | Why |
|---|---|---|---|
| D1 | Scope | **Reskin, not rebuild** | Content is current/committed (N2 IA, `page.tsx:24`); only identity is stale |
| D2 | Token strategy | **Remap var VALUES 1:1 by scale stop**, keep var NAMES | Components reference vars → one cascade restyles all (PR1 method) |
| D3 | Hardcoded hex | **Literal remap table** applied to `app/**/*.{tsx,ts,css}` | Catches Clerk/SVG/style-block hex + stale fallbacks in one auditable pass |
| D4 | Font | **Hanken Grotesk + JetBrains Mono** via `next/font/google` | Design typeface; `next/font` keeps SSR/perf |
| D5 | Light theme | **Defer** (keep dark-only) | Landing has no toggle today; adding one is out of reskin scope (§8) |
| D6 | Proof/benchmark copy | **Untouched** | Honest-stats — conservative framing is deliberate; upgrading to 87.66 SOTA is a founder call |
| D7 | Deploy/env (Clerk/Stripe prod keys, Vercel) | **Documented, not executed** | EXTERNAL-DEP, founder-provisioned at deploy (§6) |
### Token remap table (old cool → warm-Hive, verbatim from `waggle.css`/`apps/web` PR1)
```
hive 950 #08090c→#0e0c07 900 #0c0e14→#14110b 850 #11141c→#1a160f 800 #171b26→#1f1a12
700 #1f2433→#272117 600 #2a3044→#4a4030 500 #3d4560→#6b6250 400 #5a6380→#948a73
300 #7d869e→#c8bfa9 200 #b0b7cc→#d8cfba 100 #dce0eb→#ece3d0 50 #f0f2f7→#f6f1e4
honey 600 #b87a00→#c07e16 500 #e5a000→#e9a52c 400 #f5b731→#f6c45a 300 #fcd34d→#f9d27e
sem status-ai #a78bfa→#b196dd status-healthy #34d399→#6cb78c
rgb honey 229,160,0 → 233,165,44
```
---
## 4. Architecture (do NOT recreate)
- Keep next-intl/Clerk/Stripe wiring, the hero A/B variant resolver infra, the `_data`/`_lib`
modules, legal routes, sitemap, event taxonomy — all untouched by a reskin.
- Reuse the existing inline-CSSProperties + global-CSS pattern. No Tailwind, no new CSS framework.
- Named warm tokens (`--bg`,`--surface`,`--text`,`--line`, semantics) are *added* alongside the
remapped `--hive-*`/`--honey-*` scales for future use, but components keep using the scale vars.
---
## 5. No-fabrication contract (F-traps gated off)
| F | Trap | Gate |
|---|---|---|
| F1 | Upgrading proof copy to 87.66 SOTA under cover of "reskin" | Benchmark/proof strings in `en.json` **left byte-identical** |
| F2 | Inventing testimonials (`proof.human_quote` is intentionally empty) | Leave empty |
| F3 | Claiming light theme works when not added | Don't add toggle; note as deferred |
| F4 | Drifting tier prices/names while editing | Pricing copy untouched (color-only) |
| F5 | "Deployed/live" claims | Smoke is local `next dev`; deploy is EXTERNAL-DEP, documented not executed |
---
## 6. EXTERNAL-DEP (founder-provisioned at deploy — not build blockers)
| Dep | Needed by | Absent behavior |
|---|---|---|
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` + secret | prod sign-in/up | Build/dev render; auth round-trip needs real keys |
| Stripe live keys + 4 price IDs + webhook secret | prod checkout | `checkout` route already fails honestly without |
| Vercel project + `waggle-os.ai` DNS | go-live | per `docs/launch/drafts/2026-05-12-apps-www-deployment-readiness.md` |
---
## 7. Phased plan + gates
- **A — Tokens & type** (`globals.css`, `layout.tsx`): remap `:root` values, honeycomb stroke,
honey rgb, font Inter→Hanken+JetBrains Mono, heading styles, Clerk appearance hex→warm.
- **B — Hardcoded-hex sweep** (`HeroVisual.tsx`, `BrandPersonasCard.tsx`, page-level fallbacks,
`methodology/page.tsx`): apply remap table; re-grep proves zero old-palette hex remains in `app/`.
- **C — Verify**: `tsc --noEmit` (apps/www) 0 · `next build` green · `next lint` clean · `vitest run` green.
- **D — Adversarial review** (workflow): design-fidelity/leak, honest-stats, security(keys), correctness, a11y/contrast. Fix HIGH+MEDIUM.
- **E — Live smoke**: `next dev`, screenshot landing + sign-in, **0 console errors**; commit
`smoke-pr8-<date>/REPORT.md` + PNGs.
- **F — Ship**: merge `--no-ff`, push origin, handoff + MEMORY.md START HERE.
**Gate (before merge):** tsc 0 · build green · lint clean · vitest green · review HIGH/MEDIUM clear · smoke 0 console errors.
---
## 8. Honesty log / deferred
- Landing stays **dark-only**; warm light-paper theme + a nav theme toggle deferred (design supports it; out of reskin scope).
- Proof copy stays conservative (67.8% AND-of-3); upgrading to the now-public 87.66 SOTA headline is a founder content decision, not this PR.
- Older `Waggle Landing.html` has 6 pillars + BYO-agent + self-evolving sections the live N2 IA folds/omits; reconciling content to the reference is a separate content PR, not this reskin.
- `methodology/page.tsx` + page fallbacks updated for hex consistency though vars already cascade.

View File

@@ -0,0 +1,301 @@
# PR3 Recon — SCREEN 02 · Chat (ship Variation B "Split work canvas")
Branch: `feature/warm-hive-redesign`. Maps the **current Chat/agent-runtime** implementation
in `apps/web` against `docs/design_handoff_waggle_app/SCREENS.md` §02 + `design-files/screens/chat.html`.
**Headline:** the conversation surface, activity stream (steps), tool cards, approval card, model
pill, persona pill, composer, artifact card, and provenance primitive **all already exist** and are
functional. The **right work-canvas (Variation B's ~42% live-drafting side panel) does NOT exist**
it must be built. There is also **no provenance pill inside the activity stream**, and the agent's
streamed "thinking" steps render as a **flat inline list, not the design's collapsible Activity
card with `⬡ provenance` pills**. The visual styling is current "Hive DS" (primary/secondary/muted,
emerald/amber/sky/violet) — it needs the warm-token migration but the structure is mostly there.
---
## 1. Current structure (with line refs)
### Component tree
```
ChatHost.tsx keep-alive portal host: 1 ChatWindowInstance per visited workspace
└ ChatHostInstance portals into per-workspace container; composes title bar
└ ChatWindowInstance.tsx data wiring: useChat + useSessions + model/team fetch
└ ChatApp.tsx ALL of the chat UI (793 lines) — single big component
├ <header> (chat-header) persona pill · Memory chip · storage/team chips · autonomy · model pill
├ Agent Profile panel (collapsible)
├ Pins bar
├ <div scrollRef> thread messages.map → bubbles
│ └ BlockRenderer (per assistant msg with blocks)
│ ├ TextBlock / StepBlock / ToolUseBlock / ArtifactBlock / ModelSwitchBlock
│ └ ToolCard (legacy tools[] path)
│ └ FeedbackButtons · suggested-action chips
│ └ ApprovalGate (pendingApproval)
└ composer (textarea + slash menu + attach + send)
```
### Key file:line anchors
- **ChatHost.tsx** — keep-alive via React portals. `ChatSlot` (`:85`) is the seam node
`WorkspaceRoute` passes into `WorkspaceDesktopApp`'s `chatSlot` prop. `ChatHostInstance` (`:96`)
portals `<ChatWindowInstance>` into a stable per-workspace `<div>` (`getChatContainer` `:57`), kept
alive (hidden, not unmounted) so in-flight SSE survives navigation. **The split canvas must live
INSIDE this kept-alive subtree** (either in ChatApp or a wrapper it renders), or the canvas state
is lost on navigation. ChatHost itself only does title + keep-alive; it is NOT the place to add a
sibling canvas pane.
- **ChatWindowInstance.tsx** — thin data layer. `FALLBACK_MODELS` (`:14`); calls `useChat` (`:98`)
and `useSessions` (`:97`); fetches model list/current-model with a 20s retry loop (`:126211`);
`handleModelChange` (`:213`). Passes ~20 props straight into `ChatApp` (`:221`).
- **ChatApp.tsx** — the entire rendered surface:
- Header bar: `:746` (`data-testid="chat-header"`). Persona picker `:765`; **Memory chip** `:815`
(always-visible trust signal, `Brain` icon); storage badge `:829`; team presence `:843`;
overflow `⋯` menu in compact mode `:867`; **AutonomyToggle** `:920`; **model picker** `:931`.
- `ToolCard` (`:99`) — legacy `msg.tools[]` render path (only used when a msg has no `blocks`).
- `ApprovalGate` (`:223`) — the inline approval card with RiskBadge + Allow once / Always allow /
Deny / Show details.
- `AutonomyToggle` (`:339`) — Ask first / Trusted / Autopilot chip + TTL dropdown.
- Thread render `:10501191`; per-message bubble `:1093`; `BlockRenderer` invocation `:1115`;
plain-content fallback `:1121`; suggested-action chips `:1160`.
- Composer `:11941236`; slash menu `:1195`; textarea `:1219` ("Message Waggle... (/ for commands)").
- `WorkspaceBriefing` empty-state `:1051` (renders when `messages.length === 0`).
- **chat-blocks/** (the progressive-disclosure renderers):
- `BlockRenderer.tsx` — switch over block.type; routes completed file-writes to `ArtifactBlock`
(`:34`), else `ToolUseBlock`.
- `StepBlock.tsx` — a single agent "thinking" step: spinner/check + description. **Flat inline
row — NOT wrapped in a collapsible Activity card and has NO provenance pill.**
- `ToolUseBlock.tsx` — collapsible tool row (status icon + name + input summary + duration + raw
JSON on expand).
- `ArtifactBlock.tsx` — the Cowork "artifact card": icon + filename + "Created/Updated by the
agent" + **Open in Files** (stashes deep-link + fires `waggle:open-app`). `isArtifactBlock` (`:21`).
- `ModelSwitchBlock.tsx` — fallback-model banner.
- **chat-header-layout.ts** — pure `shouldCollapseChatHeader(width)` decision (threshold 480px,
`:16`); `CHAT_HEADER_OVERFLOW_CONTROLS` / `CHAT_HEADER_PRIMARY_CONTROLS` classify which chips fold.
### Container / layout the canvas must slot into
`WorkspaceDesktopApp.tsx:841``{/* Body: main canvas + right context panel */}` is a
`flex-1 flex overflow-hidden` row. The chat tab renders `chatSlot ?? <placeholder>` at `:882902`
inside `<main className="flex-1 min-w-0 overflow-auto">`. The chat slot is given the full main
column. **The split canvas should be implemented as a horizontal flex INSIDE ChatApp's own root
`<div className="flex h-full relative">` (`:697`)** — append the canvas `<aside>` as a sibling of
the existing chat `<div className="flex flex-col flex-1 min-w-0">` (`:744`). That keeps it within
the kept-alive portal subtree and reuses ChatApp's existing flex root.
---
## 2. Data contract — how streamed turns / activity / artifacts arrive
### Source of truth: `useChat.ts` (apps/web/src/hooks/useChat.ts)
- Returns `{ messages, isLoading, sendMessage, clearHistory, pendingApproval, approveAction }` (`:325`).
- `sendMessage` (`:93`) appends a user `ChatMessage` then an empty assistant `ChatMessage`
(`blocks: []`), then **iterates `adapter.sendMessage(...)` as an async generator of `StreamEvent`**
(`:125`), reducing each event into the LAST assistant message's `blocks[]` immutably (`:130262`).
- **StreamEvent types** (`lib/types.ts:616`): `'token' | 'step' | 'tool_start' | 'tool_end' | 'done'
| 'error' | 'approval_request' | 'approval_required' | 'model_switch' | 'notification'`.
Adapter maps SSE event names to these in `adapter.ts:722731` (`tool`→`tool_start`,
`tool_result`→`tool_end`, etc.).
- **Event → block reduction** (`useChat.ts`):
- `token` (`:141`) → appends/extends the trailing `TextContentBlock`.
- `step` (`:152`) → marks prior running steps done, pushes a new `StepContentBlock{status:'running'}`.
**This is the "activity/thinking" stream — currently a flat sequence of StepBlocks, not grouped.**
- `tool_start` (`:167`) → pushes a `ToolUseContentBlock{status:'running'}` AND mirrors into legacy
`tools[]`.
- `tool_end` (`:183`) → flips the matching tool block to `done` with `result`/`duration`.
- `model_switch` (`:214`) → `ModelSwitchContentBlock`.
- `error` (`:225`) → `ErrorContentBlock`.
- `done` (`:231`) → marks all running blocks done; appends final text if none present.
- `approval_request` / `approval_required` (`:248`) → `setPendingApproval(data)` — does NOT mutate
blocks; surfaced as a single `pendingApproval` slot (one at a time).
- `content` is kept in sync via `flattenBlocks` (`:45`) for copy/pins/search.
- **History load**: `getHistory(workspaceId, sessionId)` → `ensureBlocks` backfills `blocks[]` for
legacy messages (`:14`, `:8391`).
- **Adapter wire**: `adapter.sendMessage` (`adapter.ts:686`) POSTs `/api/chat` with
`{workspaceId, message, sessionId, persona, autonomy, shape}` and parses SSE lines into StreamEvents.
### ContentBlock shapes (`lib/types.ts:461503`)
`TextContentBlock{type,blockId,content}` · `StepContentBlock{type,blockId,description,status}` ·
`ToolUseContentBlock{type,id,name,input?,status,result?,duration?}` ·
`ModelSwitchContentBlock{type,blockId,from,to,reason}` · `ErrorContentBlock{type,blockId,message}`.
`ChatMessage{id,role,content,blocks?,timestamp,tools?,feedback?,pinned?,persona?}` (`:366`).
`ApprovalRequest{requestId,toolName,description,input,riskLevel?,approvalClass?,trustSource?,
explanation?,...}` (`:387`).
### Artifacts — how they arrive
There is **no dedicated artifact/canvas stream channel.** An "artifact" today is derived purely from
a completed `write_file`/`edit_file`/`file_write` tool block (`ArtifactBlock.isArtifactBlock` `:21`)
and rendered as an inline card. The design's **live-drafting `teardown.md` canvas has no backing
data source** — the streamed events carry no document body, only the tool's `input.path` + opaque
`result` string. **Building the canvas means either** (a) deriving its content from the most recent
file-write tool block's `input.content`/`result`, or (b) adding a new stream channel /
artifact-fetch. This is the single biggest data gap (see §5).
### Provenance data
The activity steps from the server are plain text descriptions — **the SSE `step` payload carries no
structured `source`/`provenance` field.** The design's `⬡ mem://hive · provenance kept` pill has no
backing data today; it would need either a richer `step` payload or a client-side heuristic. A reusable
provenance UI primitive already exists: `components/ui/evidence-chip.tsx` (`EvidenceChip`).
---
## 3. Test contract (must not break)
### `apps/web/src/test/chat-artifact-block.test.tsx`
- `BlockRenderer` renders `chat-artifact-block` for a completed `write_file` with a path; shows the
basename and "Created by the agent".
- `edit_file` → "Updated by the agent".
- running/error file-writes keep the generic tool row (NOT an artifact card).
- `isArtifactBlock`: false for non-file tools / missing path; true for a done write_file.
- **Open in Files** stashes the path deep-link and fires `waggle:open-app` with `appId:'files'`.
- **Contract for PR3:** keep `data-testid="chat-artifact-block"` + `chat-artifact-open`, the
Created/Updated copy, and the `isArtifactBlock` routing predicate intact. The canvas is ADDITIVE —
the inline artifact card stays.
### `apps/web/src/test/p1a-chat-state.test.tsx`
- `useChatWidgetState` persistence (persona, autonomy TTL + 10s auto-revert, P4 defaultAutonomy
inheritance) under `waggle-chat-state-v1`.
- `seedChat`/`takeChatSeed` one-shot semantics; `composeChatTitle` formatting.
- **WorkspaceDesktopApp two-seam edit (§5.2):** seam (a) controlled `activeTab` + `onTabChange`
(test IDs `ws-tab-chat`, `ws-tab-tasks`, `ws-tasks-tab`); **seam (b) the chat tab renders the
provided `chatSlot` instead of the placeholder** (test IDs `chat-slot-stub`, and asserts
`ws-chat-tab-open` is absent when a slot is given).
- **Contract for PR3:** the `chatSlot` seam + `activeTab`/`onTabChange` props are load-bearing — the
split canvas must NOT change `WorkspaceDesktopApp`'s public chatSlot contract; it lives inside the
slot's subtree (ChatApp), not as a new prop on the desktop.
### Other related tests (grep before editing)
`chat-blocks/TextBlock.test.tsx`; `context-rail-fetch.test.ts` (the `onContextRail` double-click path
in ChatApp `:1095`); header-layout behavior is pinned via `chat-header-layout.ts` consumers.
---
## 4. Design spec — Variation B, with exact copy
### Context header (replaces current `chat-header` styling)
- `wmark` hex "C" (honey gradient) + workspace name **"Competitive Intelligence"** + mono sub
**"workspace · 142 memories · 9 sources"**.
- **Model pill** (right): pill, `--surface` bg, `--line` border, healthy live dot, title
"Waggle picked the model — click to override", text **"Model: auto · Claude Sonnet"** (`b` on
"auto"). → maps to current model picker (`ChatApp.tsx:931`) but restyled as a pill with a live dot
and the "auto ·" prefix.
- **Memory icon button** (right) — `iconbtn`, brain glyph, title "Memory & context". → current Memory
chip (`:815`) becomes this icon button.
### Thread (max-width 760px; **narrows to ~620px when canvas open**)
- **User message:** `--surface` bubble, **asymmetric radius `4px 14px 14px 14px`**, 15.5px/1.55.
(Current user bubble is `bg-primary rounded-xl` `:1108` — needs warm surface + asymmetric radius.)
- **Bot message:** hex "W" avatar (honey gradient); meta line **"Waggle · Analyst · Claude Sonnet"**
(`who` `b` on "Waggle"). Prose 15.5px/1.62; bullet lists use honey `` markers.
- **Activity stream card (the "magic"):** collapsible, `--bg-2` bg, `--line-soft` border, radius
`--r`. Header: violet **spark** icon + **"Worked across memory, web & files · 6 steps · 38s"**
+ chevron (rotates 90° when open). **Default-open on the active turn.** Each step row (`.astep`):
colored dot + text + optional **provenance pill** — mono, `--intel` color, `--intel-wash` bg,
format **`⬡ mem://hive · provenance kept`**. Exact step copy from chat.html:
- (intel dot) "Recalled **6 memories** from your hive — last quarter's landscape, the Mem0
teardown, your pricing notes." → prov `⬡ mem://hive · provenance kept`
- (web/cyan dot) "Searched **9 competitor sites** for pricing & positioning `(mem0, letta,
langmem, notion…)`"
- (work/blue dot) "Compared against your **last teardown** — flagged 3 changes since March."
- (healthy dot) "Drafted **teardown.md** — exec summary, landscape table, opening."
- **Bot prose after activity:** "Done. Here's the shape of it — full draft is in **teardown.md**."
+ 3 honey-bullet items + "Want me to turn the opening into a one-page brief for the board, or
export the table to the pricing sheet?"
- **Approval card** (`--honey-wash` bg, `--attention` border, warning icon):
- Title: **"Approve before I leave your machine"**
- Body: "This step writes to an external system — `Salesforce Q2 Pricing` (mono). Everything
else stayed local. I'll export the 9-row table and nothing else."
- Actions: **"Approve & export"** (honey) / **"Not now"** (ghost).
- → maps to current `ApprovalGate` (`:223`) — restyle to honey-wash; keep RiskBadge + the
Always-allow gating logic.
### Composer
- Rounded box, `--surface`, `:focus-within` → `--honey-line` + `--honey-glow`. Textarea placeholder
**"Reply, or ask Waggle to take the next step…"**. Tool chips row: **Attach** / **Persona: Analyst**
/ **Tools**; spacer; mono hint **"⏎ send · ⌘K commands"**; honey send button (``, 40px).
→ current composer (`:1194`) restyle; placeholder + hint copy change; persona/tools become chips.
### Variation B — right work canvas (~42% width)
- `<aside class="canvas">` `--bg-2`, `--line-soft` left border, `width:42%` (transition .25s).
- Canvas head: title **"teardown.md"** + healthy mono tag **"● live draft"** + an "Open in
Artifacts" icon button (external-link glyph).
- Canvas body = `.doc`: H1 **"Q2 Competitive Teardown"**; mono docsub **"draft · 9 competitors ·
updated just now by Waggle"**; honey mono H2 section labels ("Executive summary", "Landscape");
a landscape `<table>` (Player / Memory / Local-first: Mem0 Cloud No · Letta Agent-centric Partial
· LangMem Toy-tier No · Notion AI Doc-scoped No); a trailing paragraph with a **blinking honey
type-cursor** (``, `@keyframes bl` 1s steps(2)).
- Responsive: `@media (max-width:820px)` hides the canvas (chat goes full width).
### Interactions
- Clicking an activity header toggles its steps (current StepBlocks have no grouping/toggle — new).
- Canvas type-cursor blinks on the live draft.
---
## 5. Gap table (current → design)
| # | Area | Current | Design (Variation B) | Gap / action |
|---|------|---------|----------------------|--------------|
| 1 | **Right work canvas** | **Does not exist** anywhere (`grep canvas` → only WorkspaceDesktopApp layout comments + an unrelated KG viz). Chat is single-pane. | ~42% live-drafting `teardown.md` aside with header, doc body, blinking honey cursor; thread narrows to 620px when open. | **BUILD NEW.** New `<ChatWorkCanvas>` aside as sibling of ChatApp's chat column (`:744`). Needs open/close state + a data source for the doc body (none exists — see #2). |
| 2 | **Canvas content source** | No artifact body in the stream — only tool `input.path` + opaque `result`. | Live document with sections/table that updates as the agent drafts. | **DATA GAP.** Derive from latest file-write tool block's `input.content`/`result`, OR add a stream channel / artifact fetch. Simplest PR3: show the last completed artifact's rendered content; "live drafting" cursor is cosmetic. |
| 3 | **Activity stream grouping** | Flat `StepBlock` rows inline (`StepBlock.tsx`), no card, no toggle, no header summary. | Collapsible Activity card with violet spark + "Worked across … · N steps · Ns" header + chevron; default-open on active turn. | **BUILD.** New `ActivityCard` that groups consecutive `step` (and tool) blocks; BlockRenderer must collapse a run of steps into one card. Derive "N steps · Ns" from the grouped blocks. |
| 4 | **Provenance pill in steps** | None (SSE `step` carries no source field). `EvidenceChip` primitive exists but unused in chat. | `⬡ mem://hive · provenance kept` mono `--intel` pill per step. | **DATA + UI GAP.** No backing data. PR3 can render the pill only when a step/tool exposes a source; otherwise omit (don't fabricate). Reuse `EvidenceChip` styled to `--intel`/`--intel-wash`. |
| 5 | **Model pill** | Picker button: `Cpu` icon + raw model string + chevron (`:931`). | Pill w/ healthy live dot + "Model: auto · Claude Sonnet"; honey-line hover. | **RESTYLE.** No "auto" sentinel surfaced today; show "auto ·" when model is the default/unset, plus a live dot. |
| 6 | **User bubble radius/color** | `bg-primary text-primary-foreground rounded-xl` (`:1108`). | `--surface` bubble, asymmetric `4px 14px 14px 14px`. | **RESTYLE** to warm surface + asymmetric radius. |
| 7 | **Bot meta line** | Avatar + Sparkles prefix; no "Waggle · Analyst · Claude Sonnet" meta row. | hex W avatar + meta "Waggle · Analyst · Claude Sonnet". | **ADD** meta row above bot prose (persona name + model). |
| 8 | **Approval card** | `ApprovalGate` honey/amber-ish, RiskBadge, Allow once/Always allow/Deny/Show details (`:223`). | honey-wash card, "Approve before I leave your machine", "Approve & export"/"Not now". | **RESTYLE + copy.** Keep RiskBadge + Always-allow gating; warm tokens; external-write framing copy. |
| 9 | **Composer copy + chips** | placeholder "Message Waggle... (/ for commands)"; Paperclip + Send only; no persona/tools chips, no "⏎ send · ⌘K" hint. | placeholder "Reply, or ask Waggle…"; Attach/Persona/Tools chips + mono "⏎ send · ⌘K commands" hint; honey ↑ send. | **RESTYLE + ADD** chips row + hint; new copy. |
| 10 | **Header sub / context** | persona pill + Memory chip + storage/team chips + autonomy + model (no "142 memories · 9 sources" sub, no workspace hex). | hex avatar + name + mono "workspace · 142 memories · 9 sources" + model pill + memory icon. | **RESTYLE.** Add hex avatar + memory/sources sub (data from workspace context). Autonomy/storage/team chips not in design header — relocate or drop into ⌘K/overflow. |
| 11 | **Warm tokens** | Hive DS classes (`bg-primary`, `text-emerald-400`, `bg-secondary`, `border-border`, etc.). | Warm graphite/paper tokens (`--surface`, `--honey`, `--intel`, `--bg-2`, `--line-soft`). | **MIGRATE** color classes to the PR1 warm tokens (already in `index.css`). |
| 12 | **Activity default-open on active turn** | StepBlocks always visible (no collapse). | Activity card default-open on the active turn, collapsed on prior turns. | **BEHAVIOR.** Track which turn is active; collapse historical activity cards. |
**Not gaps (already correct / reusable):** SSE streaming + block reduction (`useChat`), tool cards,
artifact card + Open-in-Files, feedback buttons, slash menu, pins, persona picker, autonomy toggle,
keep-alive across navigation, the `chatSlot` seam, `EvidenceChip` provenance primitive, warm tokens
in `index.css`.
---
## 6. Reuse + build
### Reuse as-is
- **`useChat.ts`** — the entire stream/reduction contract is design-compatible. The Activity card +
canvas are pure RENDER concerns over the existing `blocks[]`; no hook change required for a basic
build (canvas content derivation may want a small selector helper).
- **`ChatHost.tsx` keep-alive + `ChatSlot` seam** — unchanged. Canvas state lives inside the
kept-alive ChatApp subtree, so it survives navigation for free.
- **`ArtifactBlock.tsx`** — keep the inline card AND reuse its `iconFor`/`isArtifactBlock`/path logic
to feed the canvas (open the latest artifact in the canvas instead of/in addition to "Open in Files").
- **`EvidenceChip`** (`components/ui/evidence-chip.tsx`) — base for the `⬡ provenance` pill (restyle
to `--intel`).
- **`ApprovalGate`** + `RiskBadge` + `canAlwaysAllow` — keep logic, restyle to honey-wash.
- **`AutonomyToggle`**, slash menu, pins, `FeedbackButtons`, `WorkspaceBriefing` — keep.
- **`chat-header-layout.ts`** — keep the overflow decision (chips not in the design header fold here).
### Build new
- **`ChatWorkCanvas` aside** — append as a sibling of ChatApp's chat column inside ChatApp root
(`ChatApp.tsx:697` flex root; chat column `:744`). Props: `open`, `artifact` (path + body), `onClose`.
Width 42%; thread column `max-width` drops to ~620px when open. Honor `@media (max-width:820px)` →
hide canvas. This keeps it within the keep-alive portal subtree (do NOT add it as a `WorkspaceDesktopApp`
pane — that breaks the `chatSlot` test contract and lives outside keep-alive).
- **`ActivityCard`** — a grouping wrapper in `chat-blocks`: BlockRenderer collapses a consecutive run
of `step` (+ optionally tool) blocks into one collapsible card with the "Worked across … · N steps
· Ns" header (derive count from grouped steps; duration from summed/last tool durations if present),
violet spark icon, chevron toggle, default-open when `isStreaming`/active turn.
- **Canvas content selector** — derive the canvas doc from the most recent completed
`write_file`/`edit_file` block (path + `input.content`/`result`); render markdown via the app's
existing markdown path (TextBlock already renders prose — reuse its renderer for the doc body).
The blinking type-cursor is cosmetic, shown while `isStreaming`.
- **Provenance data** — render the step provenance pill ONLY when the data exists; do not fabricate.
Flag to backend owners that the SSE `step` payload needs a structured `source` field to fully match
the design (`docs/redesign-warm-hive/pr3-recon` follow-up).
### Is there already an artifact-canvas / side panel? — NO.
Confirmed by grep across `apps/web/src` for `canvas` / `work canvas` / `artifact-canvas`: the only
matches are `WorkspaceDesktopApp.tsx` layout comments (an unrelated "right context panel" on the
Overview tab, NOT a chat canvas) and a knowledge-graph canvas in Memory. The chat surface
(`ChatApp.tsx`) is strictly single-pane (`flex h-full` with one `flex-col flex-1` column, `:697`/`:744`).
**The split work canvas must be built from scratch.**
### Risk notes
- Canvas MUST live inside the kept-alive subtree (ChatApp), else its state resets on every nav
(`ChatHost.tsx` keep-alive only covers the portal subtree).
- Do not change `WorkspaceDesktopApp`'s `chatSlot`/`activeTab` props — `p1a-chat-state.test.tsx`
seam (a)/(b) pins them.
- Keep `chat-artifact-block` / `chat-artifact-open` test IDs + Created/Updated copy + `isArtifactBlock`
predicate — `chat-artifact-block.test.tsx` pins them; the canvas is additive.

View File

@@ -0,0 +1,293 @@
# PR3 Recon — SCREEN 01 · Home / Cockpit (ship Variation A "Editorial")
Maps the **current** Home/Cockpit implementation against the warm-Hive design spec for
SCREEN 01. Scope: what renders today, the live data contract, the test contract that
must keep passing, the Editorial target with exact copy, the gap list, and the
reuse/build plan.
Primary files:
- Current UI: `apps/web/src/components/os/apps/HomeCockpit.tsx` (the real Home; **this is the one PR3 rebuilds**)
- Route wrapper: `apps/web/src/routes/HomeRoute.tsx`
- `CockpitApp.tsx` (`apps/web/src/components/os/apps/CockpitApp.tsx`) is a **separate** system-health dashboard (Mission-Control-style), **NOT** the Home surface — see note in §1.
- Server contract: `packages/server/src/local/routes/home.ts`
- FE types: `apps/web/src/lib/types.ts:266-331`
- Test contract: `apps/web/src/test/p2-home-desktop.test.tsx`
- Design: `docs/design_handoff_waggle_app/SCREENS.md` §"01 · Home / Cockpit" + `design-files/screens/home.html` (view `#view-a`)
---
## 1. Current structure — what `HomeCockpit.tsx` renders today (section by section)
`HomeCockpit` is the default `/home` surface (`HomeRoute.tsx:13`). It is a single
scrolling column, `p-6 max-w-3xl mx-auto` on the sub-states and `max-w-4xl mx-auto` on
the normal render (`HomeCockpit.tsx:580`). Data loads in `load()`
(`HomeCockpit.tsx:467-497`): `adapter.getHomeBriefing()` then a best-effort
`adapter.getHomeOvernight()`; deferred until `useService().connecting` settles
(cold-load 401 race guard, `:499-507`).
**Render states (root, `:509-650`):**
1. **Loading**`CockpitSkeleton` (`:84-98`), `data-testid="home-cockpit-loading"`.
2. **Permission denied (403)**`:514-527`, `data-testid="home-cockpit-permission-denied"`.
3. **Load error / offline**`:531-554`, `data-testid="home-cockpit-error"` + Retry.
4. **First-run empty**`FirstRunEmpty` (`:101-125`), `data-testid="home-cockpit-empty"`, "Create your first workspace" CTA.
5. **Normal**`:579-649`, `data-testid="home-cockpit"`.
**Normal render, top to bottom:**
- **`GreetingHeader`** (`:136-159`, rendered `:581`): H1 = `briefing.greeting` (`text-2xl font-display font-bold`), sub = `formatBriefingDate(briefing.date)` rendered as a human date (`:130-134`). Right side: optional offline "Local only" pill + a static `Ctrl+K` chip (`:153-156`). **No mono date row, no live dot, no streak chip.**
- **Attention banner** (`:583-599`, `data-testid="home-cockpit-attention-banner"`): only when `overnight.failures.length > 0`. Honey-tinted `role="alert"`.
- **J08 review banner** (`:601-625`, `data-testid="home-cockpit-review-banner"`): only when `briefing.needsReviewCount > 0`; "N imported memories need your review" + Review CTA that dispatches `waggle:open-app {appId:'memory', filter:'unreviewed'}` (`:573-577`).
- **`RecentWorkspacesPanel`** (`:162-227`, rendered `:627-632`): section head **"You were working on"** (`:174-176`), 2-col grid (`sm:grid-cols-2`). Each card: name + `group` chip, optional 2-line `summary`, `lastActive` relative time + `pendingCount` "pending" warning, a **"Continue →"** button (`:208-215`), and a `WorkspaceActionsMenu` kebab (G1 rename/archive/delete). Card body click → `onOpenDesktop`. Returns null when 0 cards.
- **`OvernightPanel`** (`:230-288`, rendered `:637`, suppressed when offline): section head **"Overnight"**, a 3-counter grid (Memories consolidated / Artifacts created / Automations completed), then a failures block (deep-links each failure to the Automation Center logs, `:271-280`). Hidden when no activity.
- **`SuggestedActionsPanel`** (`:321-343`, rendered `:639`): section head **"Suggested next actions"**, wrap of pill buttons from `briefing.suggestedActions`, each → `onContinue(a.workspaceId, a.sessionId)`.
- **`UpNextPanel`** (`:291-318`, rendered `:643`): section head **"Up next"**, list of up to 6 items (event/task/schedule icons). Returns null when 0 items.
- **`QuickCapturePanel`** (`:346-434`, rendered `:645`): "Quick capture" — a 4-kind segmented selector (note/task/link/file) + text input + Capture button → `adapter.quickCapture()`. **Not in the Editorial design.**
- **`ActiveModelsTile`** (`:437-448`, rendered `:647`): tiny "Active models: …" chips row when `briefing.activeModels` present.
**Styling today:** uses shadcn semantic classes (`bg-secondary/30`, `border-border/30`,
`text-muted-foreground`) and inline `var(--sem-intelligence)` / `--sem-attention` /
`--sem-work` / `--sem-healthy` / `--sem-risk` semantic tokens. Layout is **dense and
utilitarian** (10-13px text, `rounded-xl`, compact panels) — the opposite of the
Editorial spec's calm, large-type, story-led 920px column.
> **Note on `CockpitApp.tsx`:** despite the name, this is the system-health/ops
> dashboard (System Health, Cost, Memory Weaver, Cron, Connectors, Audit Trail,
> ComplianceDashboard) on a 30s refresh. It is **not** the Home surface and **not in
> scope for SCREEN 01** — it corresponds to "Mission Control" (SCREENS §16) /
> "surfaces" (§08). Listed in the task only to disambiguate; PR3 Home work happens
> entirely in `HomeCockpit.tsx`.
---
## 2. Data contract — `HomeBriefing` shape + feeding routes (real vs mocked)
### Types (FE mirror `apps/web/src/lib/types.ts:266-331`; server `home.ts:43-95`)
```ts
interface HomeBriefing {
greeting: string; // server builds via buildTimeAwareGreeting + personalizeGreeting (home.ts:401-407)
userName?: string; // from IdentityLayer on personal mind (home.ts:261-270); optional
date: string; // now.toISOString() (home.ts:412) — RAW ISO, FE must humanize
recentWorkspaces: RecentWorkspaceCard[];
suggestedActions: SuggestedAction[];
upNext: UpNextItem[];
activeModels?: string[]; // OPTIONAL — server briefing NEVER populates it (no producer in home.ts)
isFirstRun: boolean; // ranked.length === 0 (home.ts:418)
needsReviewCount?: number; // J08 unreviewed personal-mind frames (home.ts:382-389)
}
interface RecentWorkspaceCard {
id; name; group; // group = workspace.group string
summary?; // state.recentDecisions[0].content (home.ts:314-316)
lastActive: string; // ISO; ranking timestamp
pendingCount: number; // state.pending.length + state.blocked.length
continueSessionId?; // NOTE: server never sets it (home.ts card builder omits) → Continue has no session seed
}
interface SuggestedAction { label; workspaceId; sessionId?; kind; } // kind always 'next-action' from server
interface UpNextItem { id; label; workspaceId?; at?; kind: 'event'|'task'|'schedule'; }
// server only ever emits kind:'schedule' from cron; 'event'/'task' are type-only
// server NEVER sets `at` → UpNextPanel's time column is always empty today
interface OvernightSummary { // GET /api/home/overnight (separate call)
consolidated; artifactsCreated; automationsCompleted;
failures: OvernightFailure[]; // {id,label,automationId?,error,at} from cron execution history
window?: { from; to };
}
```
### Routes feeding Home
| Route | Method | Adapter | Source / realness |
|---|---|---|---|
| `/api/home/briefing` | GET | `adapter.getHomeBriefing()` (`adapter.ts:612-615`) | **Real.** Server-side cross-workspace fan-out (`home.ts:257-423`); personal-only (A2), excludes team/archived. Reuses `buildWorkspaceState`, `buildTimeAwareGreeting`, `buildUpcomingSchedules`, `IdentityLayer`, cron store, audit DB. |
| `/api/home/overnight` | GET | `adapter.getHomeOvernight(since?)` (`adapter.ts:617-621`) | **Real.** Audit-event counts (`memory_write` → consolidated; file-write `tool_call` → artifacts) + cron execution history → automations/failures (`home.ts:425-503`). |
| `/api/quick-capture` | POST | `adapter.quickCapture()` (`adapter.ts:623-627`) | **Real.** Used by `QuickCapturePanel` (not in Editorial layout). |
### Real vs mocked / missing, per design need
- **REAL:** greeting, userName, date (ISO), recentWorkspaces (name/group/summary/lastActive/pendingCount), suggestedActions, upNext (schedule labels only), overnight counters + failures, needsReviewCount.
- **NOT produced by the server (treat as absent):**
- `continueSessionId` — never set → "Continue" can't target a session (already a known gap, `HomeRoute.tsx:16-19`).
- `UpNextItem.at` (time) and `event`/`task` kinds — never set; only `schedule` from cron.
- `activeModels` — typed-optional, **no producer**; tile never shows from the real route.
- **NO DATA ANYWHERE (must mock for the Editorial design):**
- **🔥 streak chip** — `grep` across `packages/server/src` and `apps/web/src` finds **no** `streak` field. Habit-loop spec (SCREENS §15) says the streak lives on Home but it is unimplemented. **Needs-mock** (or a follow-up backend field).
- **Overnight "story" sentence** + **run chips with labels** ("Teardown drafted · 9 competitors") — server gives raw counts, not a composed narrative or per-run labels. The narrative must be **composed client-side** from `OvernightSummary` (counts + failures), and the first "Teardown drafted" run chip has **no backing field** (would need the top suggestedAction/workspace summary as a proxy) → **partial mock**.
- **Workspace card hex avatar glyph / "agent live" badge** — no `glyph`/`live` field; derive glyph from `name[0]`, and there is no per-card live-agent flag → **derive / mock badge**.
---
## 3. Test contract — `apps/web/src/test/p2-home-desktop.test.tsx`
These assertions constrain the rebuild (the `describe('HomeCockpit (P2)')` block,
`:73-129`). All must keep passing. The harness renders `HomeCockpit` bare with mocked
`adapter`, `useService` (`{connecting:false, connected:true}`), offline=false, and a
stub `ShellContext` (`:38-46`).
Required behaviors / DOM contract:
1. **Root testid** — after load, `screen.getByTestId('home-cockpit')` must exist (`:81`). **Keep `data-testid="home-cockpit"` on the normal-render root.**
2. **J08 review banner** (`:84-98`) — with `needsReviewCount:3`, `data-testid="home-cockpit-review-banner"` renders text containing **"3 imported memories need your review"**, and `data-testid="home-cockpit-review-cta"` click dispatches exactly `waggle:open-app` with detail `{ appId:'memory', filter:'unreviewed' }`. **Keep the banner, its copy pattern, the CTA testid, and the event payload.**
3. **Banner omitted at 0/undefined** (`:100-103`) — no `home-cockpit-review-banner` when `needsReviewCount` is undefined.
4. **Up next omitted when empty** (`:105-111`) — no `home-cockpit-upnext` when `upNext` is `[]` or undefined.
5. **Up next present with items** (`:113-118`) — `home-cockpit-upnext` contains the item label ("Weekly digest") when ≥1 item.
6. **Human date** (`:120-128`) — the raw ISO (`2026-06-11T07:42:13.512Z`) must **not** appear; the date must render via `new Date(iso).toLocaleDateString(undefined, {weekday:'long', month:'long', day:'numeric'})`. **Keep `formatBriefingDate` semantics; the mono date row must humanize, not print ISO.**
The mocked briefing factory (`:57-67`) defines the minimum shape the component must
tolerate: `{greeting, userName:'Marko', date, recentWorkspaces:[], suggestedActions:[],
upNext:[], isFirstRun:false, needsReviewCount:0}`. The Editorial rebuild must still
render `home-cockpit` with all-empty arrays (no crash on empty overnight/workspaces).
**Implication:** PR3 may freely restyle and re-lay-out, but must preserve these
testids + behaviors: `home-cockpit`, `home-cockpit-review-banner`,
`home-cockpit-review-cta` (+ event payload), `home-cockpit-upnext` (present/absent
rules), and human-date rendering. The `up next` empty/present rule means the Editorial
"Up next" (if kept) stays conditional. Other testids (`home-cockpit-overnight`,
`home-cockpit-suggested`, `home-cockpit-continue-*`, quickcapture testids) are **not**
asserted in this file — they can be renamed/removed if their features are reshaped.
---
## 4. Design spec (Editorial / Variation A) — target sections + exact copy
From `SCREENS.md` §01 (ship = Variation A) and `home.html` `#view-a` (`:157-186`).
**Layout:** single centered column, **max-width 920px, 46px top padding**
(`home.html:31`). Sections top to bottom:
### 4.1 Greeting (`home.html:158-161`)
- **Mono date row** (`--honey`, uppercase, `.1em` tracking) with a **live dot**
(`.dot-live`, `--healthy`) on the left, and a **right-aligned 🔥 streak chip**
(pill, `--honey-wash` bg, `--honey-line` border, `--honey` text).
- **H1** Hanken 600, `clamp(34px,5vw,52px)`, line-height 1.02; the keyword **"ahead"**
is honey (`em`, not italic; `--honey`).
- **Exact copy:** date `"Friday · June 14 · 8:42"` (compose from `briefing.date`);
streak `"🔥 12-day streak"`; H1 = **"Good morning, Mara."** / **"You're _ahead_ of yesterday."**
- Maps to: greeting → `briefing.greeting` (real, already personalized server-side; the design's literal "Good morning, Mara." is `buildTimeAwareGreeting`'s output). "You're ahead of yesterday" second line + the honey "ahead" → **needs-mock / composed** (no "ahead vs yesterday" signal exists). Date → **real** (`briefing.date`, reformatted with time). Streak → **needs-mock**.
### 4.2 Overnight hero card (`home.html:163-172`)
- Radius `--r-xl`, gradient `--surface → --surface-2`, `--shadow`, soft honey radial
glow top-right (`::after`). Mono eyebrow with `--intel` dot.
- **Eyebrow (exact):** "While you slept".
- **Story line (exact):** Hanken 600, clamp 21→28px, max-width 30ch; honey key numbers (`b`):
**"Waggle finished the _Q2 competitor teardown_, folded _14 new memories_ into the hive, and ran into _one snag_ worth a look."**
- **Run chips (exact, status dot + label):**
- `"Teardown drafted · 9 competitors"` (dot `--healthy`)
- `"14 memories consolidated"` (dot `--intel`)
- `"2 artifacts created"` (dot `--work`)
- `"1 export failed"` (dot `--risk`)
- Maps to: counters are **real** (`overnight.consolidated`, `.artifactsCreated`, `failures.length`). The **composed sentence** + the **"Teardown drafted · 9 competitors"** label are **needs-mock/composed** (no narrative producer; no per-run "9 competitors" field). Build the sentence from counts + failures client-side; degrade gracefully when overnight is null/empty (must still render `home-cockpit`).
### 4.3 "Pick up where you left off" (`home.html:174-175, 287-299`)
- Section head = mono uppercase `--text-dim` with trailing hairline rule (`.sec-h`).
- 2-col grid (`.ws-grid`) of `.ws-card`: **hex avatar** (honey gradient, glyph =
first letter), **title** (16px, 650), **time** (mono `--text-dim`, e.g. "2h ago"),
**summary** (13.5px `--text-muted`), footer = **"Continue →"** (honey) + optional
**status badge** (`pend` honey-wash "3 to review" / `live` healthy-wash "agent live").
Hover: honey border + `translateY(-3px)` + `--shadow`.
- Maps to: **real**`recentWorkspaces[].name`, `.summary`, `formatRelative(lastActive)`, `.pendingCount` (→ "N to review"/"N pending"). Hex glyph = derive from `name[0]`. "agent live" badge → **no field, mock/omit**. **Section head copy must change** "You were working on" → **"Pick up where you left off"**.
### 4.4 "Waggle suggests" (`home.html:177-178, 301-306`)
- Section head "Waggle suggests". Stacked **`.move` rows**: tinted icon tile
(`--*-wash` bg), **title** (14.5px 600) + **sub** (12.5px `--text-muted`), arrow
that slides on hover.
- **Exact sample copy (design data):** "Review the competitor teardown" / "9
competitors · ready since 02:40 · ~6 min read"; "Send the board update" / "Draft
built from this week's work in Q2 Board Deck"; "Confirm 3 imported memories" / "From
Tuesday's pricing call — Waggle wants your sign-off".
- Maps to: title → **real** `suggestedActions[].label`. The **sub-line** has no
backing field (server emits label only, `kind:'next-action'`) → **needs-mock/derive**
(e.g. workspace name + relative time). Section head copy "Suggested next actions" →
**"Waggle suggests"**. Rows become **stacked** (not pills).
### 4.5 Ask bar (`home.html:180-185`)
- Full-width pill (`.ask`), honey **"+"** icon left, text input
(placeholder **"Start something new — "draft the board update from this week's
work"…""**), mono **"⌘K"** hint, honey round **send** button ("→"). Focus → honey
border + glow.
- Maps to: **net-new on Home.** No current "ask bar" on HomeCockpit. Wire send →
`onContinue`/new-chat or open ⌘K (PR2 `CommandCenter`). This **replaces** the
current QuickCapturePanel as the primary input affordance. The "+" can keep a
quick-capture role, but the design's primary intent is "start a task".
### 4.6 Not in Editorial (drop or relocate)
- **Quick capture segmented panel** — replaced by the ask bar; the
`adapter.quickCapture` API can be kept behind the "+" icon or dropped from Home.
- **Active models tile** — not in Editorial (and route never populates it). Drop.
- **Attention/review banners** — not literally in the Editorial mock, but the **J08
review banner is test-locked** (keep it; can be styled as a run-chip-adjacent
attention row or kept above the hero). The overnight-failure attention banner maps
naturally onto the "1 export failed" run chip + (optionally) Variation C's risk
alert pattern.
---
## 5. Gap list
| Design section | Current state | Gap | Data available? | Severity |
|---|---|---|---|---|
| 920px centered column, 46px top pad | `max-w-4xl mx-auto p-6` dense | Re-layout to 920px / `pt-[46px]`, larger type scale | n/a (layout) | MED |
| Mono date row + live dot | Plain `text-sm` sub under H1, no dot | Add mono uppercase honey date row + `.dot-live` | date = real; format with weekday+time | MED |
| 🔥 streak chip (right pill) | Absent | Add streak chip | **No data** — needs-mock or new backend field | HIGH |
| H1 with honey "ahead" keyword | H1 = raw greeting, no honey span | Two-line H1; honey-span a keyword | greeting real; "ahead of yesterday" composed | MED |
| Overnight **hero** card (gradient + glow) | `OvernightPanel` = 3 plain counters + failures, suppressed offline, hidden when empty | Rebuild as hero card w/ eyebrow + story + run chips; must still render when empty/null | counts real; **story + run-chip labels composed/mock** | HIGH |
| Run chips (4, status-dotted) | Counter tiles only | Render chips w/ status dots; map counts | 3 of 4 real (consolidated/artifacts/failed); "Teardown drafted · 9 competitors" mock | MED |
| "Pick up where you left off" head | "You were working on" | Copy + `.sec-h` hairline style | n/a (copy) | LOW |
| Workspace cards: hex avatar + 16px title + Continue→ + badge | Cards w/ name+group chip, Continue button, kebab | Add hex avatar (glyph from name), restyle; keep Continue + kebab | name/summary/time/pending real; glyph derived; "agent live" mock/omit | MED |
| "Waggle suggests" stacked rows w/ sub-line | "Suggested next actions" pills, label-only | Copy + restyle to `.move` rows; add sub-line | label real; **sub-line composed/mock** | MED |
| Ask bar (pill, +/⌘K/send) | None on Home (QuickCapture panel instead) | Net-new ask bar; wire to chat/⌘K | n/a (action wiring) | MED |
| `continueSessionId` for targeted resume | Plumbed but server omits | Continue lands at chat root, not a session | **Not produced** by `home.ts` | LOW |
| Quick capture panel | Present | Remove/relocate behind "+" | n/a | LOW |
| Active models tile | Present, never populated | Remove from Home | route never sets `activeModels` | LOW |
| J08 review banner (test-locked) | Present + correct | Preserve testids/copy/event while restyling | needsReviewCount real | (keep) |
| Overnight-failure attention banner | Present | Reconcile with "1 export failed" run chip | failures real | LOW |
**Gap count (distinct design-vs-impl gaps): 11** (excludes the two "keep as-is"
test-locked rows and the layout-only re-layout row counted once).
---
## 6. Reuse + build
### 6.1 Reuse (already shipped in PR1 — do NOT recreate)
All warm-Hive tokens the design references already exist in
`apps/web/src/index.css` and `apps/web/src/waggle-theme.css`:
- **Surfaces/lines/text:** `--bg --bg-2 --surface --surface-2 --line --line-soft --line-strong --text-2 --text-muted --text-dim` (`index.css:149-153`).
- **Honey + washes:** `--honey --honey-bright --honey-deep --honey-wash --honey-line --honey-glow` (`index.css:99,155-159`).
- **Semantics + washes:** `--work --intel --healthy --attention --risk` + each `*-wash` (`index.css:161-169`).
- **Radii:** `--r:12 --r-lg:18 --r-xl:26` (`index.css:177-179`).
- **Shadows:** `--shadow --shadow-sm --shadow-lg --shadow-pop` (`index.css:171-174`).
- **Fonts:** Tailwind `font-display` / `font-sans` → Hanken Grotesk, `font-mono` → JetBrains Mono (`tailwind.config.ts:87-90`). Hanken/JetBrains `@import` already in `index.css`.
- **Utilities:** `.hex` clip-path (`waggle-theme.css:140`), `.comb` honeycomb bg (`:143-146`), `.dot-live` + `@keyframes breathe` (`index.css:428-435`), `.kbd`/mono helper (`waggle-theme.css:161`).
- **Existing component primitives:** `WorkspaceActionsMenu` (kebab CRUD — keep on cards), `formatRelative` / `formatBriefingDate` (`HomeCockpit.tsx:69-81,130-134`), the adapter trio (`getHomeBriefing`/`getHomeOvernight`/`quickCapture`), `useOfflineStatus`, `useService().connecting` race guard, shadcn `ui/` (button/input/card available if wanted), lucide icons, `waggle:open-app` deep-link shim. ⌘K lives in `CommandCenter.tsx` (PR2) — wire the ask-bar "⌘K" hint / send to it.
### 6.2 Net-new (build for Editorial)
- **Mono date row** with live dot + **🔥 streak chip** (streak value mocked/constant until a backend field exists — flag in code).
- **Honey-keyword H1** (two-line; second line composed; honey-span helper).
- **Overnight hero card** — a new presentational component: eyebrow "While you slept" + composed story sentence (from `OvernightSummary` counts + failures) + 4 run chips. Must render gracefully when overnight is null/empty (no crash; the test renders with `getHomeOvernight → null`).
- **Run-chip** component (status dot + label).
- **Hex-avatar workspace card** restyle (`.ws-card` look: hex glyph, hover lift, "Continue →", badge).
- **`.move` suggestion row** restyle (icon tile + title + sub + sliding arrow); compose sub-line from workspace/time.
- **Ask bar** (pill input + honey "+" + "⌘K" hint + honey send) — replaces QuickCapture as the primary input; wire send to chat/⌘K.
### 6.3 Recommended build approach
1. **Keep `HomeCockpit.tsx` as the component** (route + props + load() + all five render
states + the test-locked testids/banners stay). Restyle/replace only the **normal
render body** (`:579-649`) and the sub-state shells to the warm Editorial look.
2. **Container:** swap `max-w-4xl mx-auto p-6` → a centered **`max-w-[920px] mx-auto px-8 pt-[46px] pb-20`** wrapper; optionally drop a `.comb` honeycomb layer behind it.
3. **Extract small presentational subcomponents** within the file (or co-located
`home/` dir, per CLAUDE.md "many small files"): `GreetingHeader` (rework),
`OvernightHero` (new, replaces `OvernightPanel`), `RunChip`, `WorkspaceCard`
(hex restyle of the existing card), `SuggestRow` (restyle of `SuggestedActionsPanel`),
`AskBar` (new). Keep `UpNextPanel` conditional (test 4/5) or fold it into the dash —
simplest is to **retain it** below suggestions to satisfy the present/absent tests.
4. **Compose, don't fetch:** the overnight **story sentence** and **run-chip labels**
are derived client-side from the existing `OvernightSummary` + top `suggestedAction`
/workspace summary. Mark the **streak** and **"ahead of yesterday"** as explicit
`// TODO(backend): no data source yet` constants so the mock is honest.
5. **Preserve behavior:** J08 banner (copy + `home-cockpit-review-cta` event payload),
human-date, `home-cockpit` root testid, `home-cockpit-upnext` present/absent rule.
6. **Dark-first** (per BUILD-PLAN §6); verify light via the existing ratchet
(`light-mode-tokens.test.ts`). Gates: `tsc -p apps/web/tsconfig.app.json` 0,
`npm run test` (FE) green incl. `p2-home-desktop.test.tsx`, lint clean.
> Honesty flags for the rebuild: (a) **streak** has no data anywhere — it is a pure
> mock until a backend field lands; (b) the overnight **narrative + "9 competitors"**
> run-chip and the suggestion **sub-lines** are composed/mocked, not server-provided;
> (c) `continueSessionId` is still omitted server-side so "Continue" lands at chat root.

View File

@@ -0,0 +1,200 @@
# PR3 Recon — Warm-Hive primitives & global patterns
> One shared vocabulary for the three PR3 screen builders (Home / Chat / Workspace).
> Post-PR1 inventory of what already exists in `apps/web` + the design's global
> patterns extracted from `docs/design_handoff_waggle_app/`. **Use the token names
> and components below — do NOT invent ad-hoc colors or re-build atoms that exist.**
Sources of truth:
- Warm tokens: `apps/web/src/index.css` (`@layer base`) + `apps/web/src/waggle-theme.css`
(note: file is at `src/`, NOT `src/styles/`).
- Tailwind utilities: `apps/web/tailwind.config.ts`
- Design global: `docs/design_handoff_waggle_app/README.md` §6/§7 + `SCREENS.md` 01/02/03
---
## 1. Warm token cheatsheet
Dark is the default `:root`; `:root[data-theme="light"]` overrides every color. Radii /
type / shadow tokens are theme-independent. Prefer the **named warm token** (`var(--x)`)
for screen chrome; the **shadcn HSL utility** (`bg-card`, `text-foreground`) for any
component that already derives from the HSL core.
### Surfaces (`apps/web/src/index.css:20-44, 149-153`)
| Token | Dark hex | Tailwind / usage |
|---|---|---|
| `--bg` | `#14110b` | `bg-background` — app shell |
| `--bg-2` | `#1a160f` | rails, recessed panels (sidebar uses `bg-[var(--bg-2)]`) |
| `--surface` *(= `--card`)* | `#1f1a12` | `bg-card` — cards |
| `--surface-2` *(= `--secondary`)* | `#272117` | `bg-secondary` — hover/insets |
| `--surface-3` *(= `--muted`)* | `#322a1d` | `bg-muted` — chips, icon tiles |
| `--line` | `#38301f` | `border-border` ≈ — default border |
| `--line-soft` | `#2a2417` | subtle dividers (card border in design = `--line-soft`) |
| `--line-strong` | `#4a4030` | emphasized border / kbd / scrollbar |
### Text (`index.css:21,36,89,153` + `waggle-theme.css:24-29`)
| Token | Tailwind / alias |
|---|---|
| `--text` *(= `--foreground`)* `#f6f1e4` | `text-foreground` |
| `--text-2` `#c8bfa9` | secondary copy |
| `--text-muted` *(= `--muted-foreground`)* `#948a73` | `text-muted-foreground` |
| `--text-dim` *(= `--hive-500` `#6b6250`)* | labels, meta, mono section labels |
### Honey — the ONE accent (`index.css:91-100, 155-159`)
| Token | Dark hex | Notes |
|---|---|---|
| `--honey` *(= `--honey-500`, `--primary`)* | `#e9a52c` | `bg-primary` / `text-honey-500`; primary btn, active nav, key numbers, focus |
| `--honey-bright` *(= `--honey-400`)* | `#f6c45a` | gradient top of hex avatar |
| `--honey-deep` *(= `--honey-600`)* | `#c07e16` | gradient bottom of hex avatar |
| `--honey-wash` | `rgba(233,165,44,.10)` | tinted fills (active nav bg, streak/honey chips) |
| `--honey-line` | `rgba(233,165,44,.28)` | tinted borders (card hover, chip border) |
| `--honey-glow` | `rgba(233,165,44,.12)` | soft glow / `--shadow-honey` |
| **On-honey ink** | `#1a1407` | text/icon color on any honey fill (matches `--primary-foreground`) |
### Semantics — desaturated, STATUS ONLY (never decoration) (`index.css:161-169`)
| Token | Dark / Light | Meaning | Wash |
|---|---|---|---|
| `--work` | `#7aa6d6` / `#3f72b0` | tasks, workspaces (blue) | `--work-wash` |
| `--intel` | `#b196dd` / `#7d57b8` | memory / intelligence / **provenance** (violet) | `--intel-wash` |
| `--healthy` | `#6cb78c` / `#3c8a5f` | complete / healthy (sage) | `--healthy-wash` |
| `--attention` | `#e9a52c` / `#b57d12` | attention / automation (= honey) | (use `--honey-wash`) |
| `--risk` | `#db8068` / `#c0573c` | risk / failure (terracotta) | `--risk-wash` |
> There is also a parallel `--sem-*` alias set (`--sem-work/-intelligence/-healthy/-attention/-risk`,
> `index.css:110-114`) wired into the EXISTING primitives (StatusBadge, ConfidenceBadge).
> The vivid `--status-*` / `bg-status-*` tokens (`#34d399` etc.) are the legacy palette —
> **prefer the desaturated `--work/--intel/...` (or `--sem-*`) for warm-Hive screens.**
### Shadows / radii / fonts (`index.css:171-183`)
| Token | Value |
|---|---|
| `--shadow-sm` / `--shadow` / `--shadow-lg` / `--shadow-pop` | card → overlay elevation |
| `--shadow-honey` | `0 0 0 1px rgba(233,165,44,.25), 0 8px 30px -10px rgba(233,165,44,.35)` |
| `--r-sm` 8px · `--r` 12px · `--r-lg` 18px · `--r-xl` 26px · pills `999px` | `rounded-sm/md/lg/xl` map to the shadcn radius scale, NOT these raw px — use `rounded-[18px]`/`rounded-[var(--r-lg)]` for design-exact cards |
| `--sans` Hanken Grotesk · `--mono` JetBrains Mono | `font-sans` / `font-mono`; `--serif` = `--sans` (no book-serif) |
> Caveat for builders: Tailwind `rounded-lg` = `--radius` (0.75rem/12px = design `--r`),
> NOT `--r-lg` (18px). Design **cards** want 18px → use `rounded-[18px]`. Design hero
> card wants 26px → `rounded-[26px]`. Pills → `rounded-full`.
### Utility classes already shipped (`index.css` + `waggle-theme.css`)
- `.hex` (`waggle-theme.css:140`) — hex clip-path for brandmark/avatars/tiles.
- `.hex-avatar` (`index.css:377`) — same clip path (duplicate; either works).
- `.comb` (`waggle-theme.css:143`) + `.honeycomb-bg` (`index.css:369`) — subtle hex mesh bg.
- `.dot-live``@keyframes breathe` 2.4s (`index.css:429-435`) — live/active status dot.
- `.heartbeat` (2s), `.honey-pulse`, `.float`, `.hex-cursor` (streaming type cursor),
`.token-stream`, `.send-flash`, `.card-enter`, `.hex-spin` — all in `index.css:408-503`
and mirrored as Tailwind `animate-*` in `tailwind.config.ts:107-156`.
- `.pill` (`waggle-theme.css:151`) — status/filter chip base.
- `.kbd` (`waggle-theme.css:160`) — keyboard hint chip.
- `.glass` / `.glass-strong` / `.glow-primary` / `.text-glow` (`index.css:334-358`).
- `.waggle-card-lift` (`waggle-theme.css:112`) — hover `translateY(-2px)` + honey border
(matches design card hover). `.waggle-interactive`, `.waggle-nav-hover`, `.waggle-press`.
- Focus: global `:focus-visible { outline: 2px solid var(--honey-500); offset 2px }`
(`index.css:389`). Selection = honey @ .28 (`index.css:383`).
---
## 2. Existing reusable components (REUSE — do not rebuild)
| Component | Path | Props | Reuse for which PR3 screen |
|---|---|---|---|
| `ConfidenceBadge` | `apps/web/src/components/ui/confidence-badge.tsx:22` | `value?: number; compact?: boolean; className?` — 0-100 → High/Med/Low band via `--sem-*`, never color-only | Workspace "What Waggle knows" fact rows; Chat memory-write steps |
| `EvidenceChip` | `apps/web/src/components/ui/evidence-chip.tsx:16` | `label: string; title?; onClick?; className?` — the inline provenance pill promoted to a primitive | Chat activity-stream provenance pills; Workspace fact/artifact `⬡ source · when` |
| `EvidencePanel` | `apps/web/src/components/ui/evidence-panel.tsx:18` | `source?; sourceId?; sourceUrl?; evidence?: string[]; className?` — "Provenance & evidence" block of chips | Workspace memory detail; Chat memory-write detail |
| `StatusBadge` | `apps/web/src/components/ui/status-badge.tsx:31` | `tone: 'healthy'\|'attention'\|'risk'\|'info'\|'neutral'; label: string; icon?; className?` — always-labeled `--sem-*` pill | Home run-chip status dots, Workspace status card, all status pills |
| `ApprovalModal` | `apps/web/src/components/ui/approval-modal.tsx:60` | `request: ApprovalRequest\|null; approveLabel?; busy?; onApprove; onCancel` (`ApprovalRequest = {action, scope[], riskLevel, approvalClass?, trustSource?}`) | Chat **inline approval card** is a different surface — but reuse this modal for the same flow + share `RISK_LABELS`/`risk-display.tsx` |
| `DetailDrawer` | `apps/web/src/components/ui/detail-drawer.tsx:22` | `open; onOpenChange; title; subtitle?; headerExtra?; footer?; children; className?` — right sheet for object detail | Workspace fact/artifact detail; Chat artifact detail |
| `BuilderStepper` | `apps/web/src/components/ui/stepper.tsx:29` | `steps: BuilderStep[]; ...` body-portaled focus-trapped modal stepper | not core to Home/Chat/Workspace; available |
| shadcn primitives | `apps/web/src/components/ui/` | card, button, badge, tabs, dialog, tooltip, popover, command, scroll-area, separator, avatar, input, textarea, switch, sheet, sonner/toast, hover-card, dropdown-menu, alert-dialog, progress, skeleton, table, +30 more | Workspace **tab bar** = `tabs.tsx`; ⌘K already on `command.tsx`; cards/buttons everywhere; toasts via `sonner` |
| `Sidebar` (calm spine) | `apps/web/src/components/os/Sidebar.tsx:44` | `workspaceName; spine: SidebarNavItem[]; pinned?; onOpen*; userName; tierLabel` — already implements **active = left honey bar + `--honey-wash` + honey icon** (`Sidebar.tsx:82-91`) and the **hex avatar** inline (`:116`) | Already the shell; reuse its active-state recipe + hex pattern verbatim |
| ⌘K catalog | `apps/web/src/lib/command-catalog.ts:58` | `buildCommandCatalog(ctx)` → plain-name + mono-subtitle groups (Pinned/Jump/Do/Power) | Chat/Home "⌘K" hints route here; don't re-author the vocabulary |
> shadcn `button.tsx` `default` variant = `bg-primary text-primary-foreground` = honey
> bg / `#1a1407` ink → already matches the design primary button. `ghost`/`outline`
> variants cover the design "ghost" button. shadcn `badge.tsx` is **color-capable but
> not always-labeled** — prefer `StatusBadge` when conveying status (a11y).
---
## 3. Missing primitives to build in PR3 (NET-NEW shared components)
These appear across Home/Chat/Workspace and have **no component today** (the hex avatar
exists only as inline markup in `Sidebar.tsx:116`). Build them once as shared atoms.
**Suggested home: `apps/web/src/components/os/warm/`** (new folder for warm-Hive-specific
composite atoms) — keep generic, token-driven, a11y-labeled primitives in `ui/` and the
opinionated warm compositions in `os/warm/`.
| Net-new primitive | Where | Minimal prop API | Used by |
|---|---|---|---|
| `HexAvatar` | `os/warm/HexAvatar.tsx` | `label: string; size?: number; gradient?: boolean; className?``.hex` clip + honey gradient `linear-gradient(150deg,var(--honey-bright),var(--honey-deep))` + `#1a1407` initial; extract from `Sidebar.tsx:116` | workspace switcher, Home workspace cards, Chat context header, Workspace header (46px), bot avatar |
| `DotLive` | `os/warm/DotLive.tsx` | `tone?: 'healthy'\|'attention'\|'risk'\|'work'\|'intel'\|'honey'; className?` — colored dot + `.dot-live` breathe; honor `prefers-reduced-motion` | Home greeting live dot, Workspace "1 agent live", status cards |
| `RunChip` | `os/warm/RunChip.tsx` | `label: string; tone?: StatusTone` — status dot + label inline chip ("Teardown drafted · 9 competitors") | Home overnight hero run-chip row |
| `StreakChip` | `os/warm/StreakChip.tsx` | `days: number; weekDots?: boolean[]; className?` — 🔥 + "12-day streak", `--honey-wash` bg / `--honey-line` border | Home greeting (§ habit-loop mechanic lives on Home, not a page) |
| `SectionLabel` | `os/warm/SectionLabel.tsx` | `children; className?` — 11px mono, uppercase, `.12-.14em` tracking, `--text-dim`, trailing hairline rule | every screen section header (README §6) |
| `ScreenHead` (`.shead`) | `os/warm/ScreenHead.tsx` | `title; subtitle?; action?: ReactNode` — H1 (Hanken 600 ~24-28px) + subtitle + primary action row | Workspace header; power surfaces; generic screen chrome (README §6 / SCREENS §08) |
| `ProvenanceLine` | `os/warm/ProvenanceLine.tsx` | `source: string; when?: string; onClick?``⬡ source · when` in mono `--intel` | Workspace fact rows + recent-work rows; Chat activity steps (thin wrapper over `EvidenceChip` styled to `--intel` mono) |
| `ModelPill` | `os/warm/ModelPill.tsx` | `mode?: string; model: string; onClick?` — "auto · Claude Sonnet" pill in Chat header | Chat context header |
| `ActivityStream` | `os/warm/ActivityStream.tsx` | `summary: string; durationMs?; steps: {tone, text, provenance?}[]; defaultOpen?` — collapsible `--bg-2` card, violet spark, per-step colored dot + `ProvenanceLine` | Chat "the magic" activity card (default-open on active turn) |
| `InlineApprovalCard` | `os/warm/InlineApprovalCard.tsx` | reuse `ApprovalRequest`; `onApprove; onDecline; alwaysAllow?``--honey-wash` bg, attention border, warning icon (NOT a modal — inline in thread) | Chat approval card (shares risk vocab w/ `ApprovalModal`) |
| `OvernightHero` | `os/warm/OvernightHero.tsx` (composite, Home-only) | `eyebrow; statement: ReactNode; runs: RunChipProps[]``--r-xl` gradient card + honey radial glow | Home (composes RunChip) |
| `AskBar` | `os/warm/AskBar.tsx` | `placeholder?; onSubmit; cmdkHint?: boolean` — full-width pill, honey `+`, ⌘K hint, honey send | Home (and Chat composer reuses the send affordance) |
| `HexCheckTile` | `os/warm/HexCheckTile.tsx` | `tone?; size?` — small `.hex` tile w/ check, for fact rows | Workspace "What Waggle knows" fact rows |
| `IconTile` | `os/warm/IconTile.tsx` | `icon: ElementType; tone?: StatusTone` — tinted (`*-wash`) rounded square icon tile | Home "Waggle suggests" rows; Workspace recent-work ext tiles |
> Build order suggestion: `HexAvatar`, `SectionLabel`, `ProvenanceLine`, `DotLive`,
> `RunChip`, `IconTile` first (shared by all three screens), then the screen-specific
> composites. Keep each <80 LOC, token-driven, `prefers-reduced-motion`-safe.
---
## 4. Global pattern rules (apply on every PR3 screen)
From `README.md` §6 and `SCREENS.md`:
1. **Density / scale:** body 16px / line-height 1.55 (set on `body`, `index.css:321`);
**never below 12px**. Honey words in headlines are honey-colored, **not italic**,
same weight. Headlines Hanken 600, `letter-spacing -0.02em` (already on `h1-h6`,
`index.css:327`).
2. **Section labels:** 11px **mono**, uppercase, `.12-.14em` tracking, `--text-dim`,
trailing hairline rule. (Sidebar zone label is the reference: `Sidebar.tsx:102`,
`9.5px mono uppercase tracking-[0.14em] text-[var(--text-dim)]`.)
3. **One accent — honey, sparingly:** primary buttons, active nav, **key numbers**,
focus only. Semantics are desaturated and status-only, never decoration.
4. **Cards:** `bg-card` (`--surface`), `1px solid` **`--line-soft`**, radius **18px**
(`--r-lg`), hover → `--honey-line` border + `translateY(-2px)` + `--shadow`. Use
`.waggle-card-lift` for the hover recipe.
5. **Active nav state:** **left honey bar** (`absolute -left-3 h-[18px] w-[3px] bg-[var(--honey)]`)
+ `--honey-wash` bg + honey icon — already implemented in `Sidebar.tsx:82-91`; mirror
on the Workspace tab bar as a **honey underline** on the active tab.
6. **Provenance everywhere:** any memory / fact / artifact shows `⬡ source · when` in
**mono / `--intel`**. Core trust pattern — never drop it. Use `ProvenanceLine` /
`EvidenceChip`.
7. **Buttons:** primary = honey bg / `#1a1407` ink (shadcn `default`); ghost = `bg-card`
/ `--line-strong` border, hover honey border. Radius 9-13px.
8. **Hex motif:** brandmark + agent/workspace avatars + icon tiles use `.hex` clip with
the honey gradient. Honeycomb (`.comb`/`.honeycomb-bg`) is decorative-only at ~.05.
9. **Motion:** entrances `cubic-bezier(.16,1,.3,1)` ~.7s, **content visible without JS /
reduced-motion**. Hover transitions .14-.18s. Live status = `.dot-live` (breathe 2.4s).
**Honor `prefers-reduced-motion`** on every animated atom.
10. **Focus / a11y:** `2px solid --honey`, 2px offset (global). Status is **never
color-only** — always a text label (StatusBadge/ConfidenceBadge pattern). Keep
keyboard access on ⌘K, tabs, composer.
11. **Dark + light:** both warm (graphite ↔ paper) via `:root[data-theme="light"]`;
drive through the app theme provider (`apps/web/src/providers/ThemeProvider.tsx`),
NOT localStorage scaffolding. Every new color must resolve from a token so light
mode inherits for free (light-mode AA is test-guarded).
### Per-screen anchor (ship-variation)
- **Home → Variation A (Editorial):** centered column max-w 920px; greeting (mono date +
DotLive + StreakChip) → OvernightHero (run chips) → "Pick up where you left off" 2-col
workspace cards (HexAvatar) → "Waggle suggests" rows (IconTile) → AskBar. (`SCREENS.md` 01)
- **Chat → Variation B (Split work canvas):** context header (HexAvatar + name + memory
count + ModelPill) → thread (user bubble asymmetric radius `4px 14px 14px 14px`; bot
hex avatar; ActivityStream w/ ProvenanceLine; InlineApprovalCard) → composer; right
work-canvas (~42%) with `.hex-cursor` live draft. (`SCREENS.md` 02)
- **Workspace → Variation A (Overview + tabs, Memory = a tab):** ScreenHead (breadcrumb +
46px HexAvatar + meta) → Tabs (Overview/Chat/Memory/Artifacts/Files/Team, honey
underline) → 2-col (1.7fr/1fr): left = summary + "What Waggle knows" (HexCheckTile +
ProvenanceLine fact rows) + "Recent work" (IconTile rows); right = Status card +
Up-next + Team. **Do NOT make the graph the default.** (`SCREENS.md` 03)

View File

@@ -0,0 +1,276 @@
# PR3 Recon — SCREEN 03 · Workspace (Variation A: Overview + tabs)
Maps the **current** Workspace surface against the warm-Hive design (`workspace.html`,
`SCREENS.md` §03). Ship target = **Variation A "Overview + tabs"**, **Memory stays a
tab** (do NOT make the knowledge-graph the default — that's the alternate Variation B,
deferred).
Primary file: `apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx` (1020 LOC).
Route wrapper: `apps/web/src/routes/WorkspaceRoute.tsx`.
Design ref: `docs/design_handoff_waggle_app/design-files/screens/workspace.html`.
---
## 1. Current structure (with line refs)
`WorkspaceDesktopApp.tsx` is a fixed-layout shell (no drag/resize grid, per its header
comment lines 9-21). Composition top-to-bottom:
- **Tab model** — 8 tabs, `WorkspaceTabId` union + `TABS` array
(`WorkspaceDesktopApp.tsx:58-77`): `overview · chat · research · artifacts · memory ·
tasks · timeline · settings`. Each tab is `{ id, label, icon }` (lucide icons). **No
per-tab counts.**
- **Header** (`:759-811`) — flat row: `<h2>` workspace name (`:761`), a **type pill**
(`:762-766`), a **status pill** (`statusPillClass`, `:767-772` + helper `:117-126`),
an inline "N running" agent indicator (`:773-778`), a **members avatar stack**
(`:783-798`, initials, max 5 + overflow), and the **`WorkspaceActionsMenu`**
(`:800-809`, rename/archive/restore/export/delete). **No breadcrumb. No hex avatar.
No meta row** (memories/sources/updated). **No "Memory"/"Continue" header buttons.**
- **Tab bar** (`:814-839`) — horizontal scroll nav, `role=tablist`. Active tab =
`border-primary` bottom border + `text-foreground` (`:827-831`); honey comes via the
`--primary` token so the underline is already honey-tinted. Icon + label per tab, **no
count badges**.
- **Body = main canvas + right context panel** (`:842-969`):
- **Overview** (`OverviewTab`, `:518-545`) — a **widget grid**
(`grid-cols-1 md:grid-cols-2 xl:grid-cols-3`, fixed 16rem row height, `:530-533`):
5 `WidgetCard`s — `ChatPreviewWidget` (read-only thread preview, `:184-232`),
`ArtifactsWidget` (file registry, `:241-283`), `TasksWidget` (pending+blocked from
state, `:286-333`), `MemoryHighlightsWidget` (decisions + "I remember", `:335-377`),
`ActivityWidget` (audit feed, `:379-401`). Empty-state when everything empty
(`:850-868`).
- **Other tabs** — `chat` renders the `chatSlot` or a deep-link placeholder
(`:882-902`); `tasks``<TasksTab>` (`:904`); `memory`
`<MemoryCenterTab mind="workspace">` (`:937-941`); `research`/`timeline`/`settings`
are `<TabPlaceholder>` stubs (`:906-957`); `artifacts` is an inline file-card grid
or placeholder (`:914-932`).
- **Right context panel** (`WorkspaceInfoPanel`, `:405-514`, `hidden lg:flex w-72`):
Workspace block (name/desc + memory/session counts, `:422-439`), **Team members**
(`:441-460`, global roster — see §2), **Last activity** + "N agents running"
(`:462-481`), **Quick actions** (Open chat / View memory / Review tasks, `:483-511`).
- **Whole-screen states** — loading (`:691-698`), `permission`/`notfound`/`offline`
error variants (`:700-754`) via `FullScreenState` (`:1000-1018`), empty-workspace
(`:850-868`).
- **Route wrapper** (`WorkspaceRoute.tsx`) — `/workspaces/:workspaceId/:tab?`; the URL
drives `activeTab` (controlled seam §5.2a, `:43-54`); `onTabChange` navigates
(overview = bare `/workspaces/:id`, `:52-53`); `chatSlot` portals the live chat
(`:57`); URL→shell sync effect (`:34-38`). The 8 tabs are pinned in `WS_TABS`
(`:20-22`).
- **`WorkspaceBriefing.tsx`** (284 LOC) — a SEPARATE component, shown **inside ChatApp**
when a thread has no messages (header comment `:1-5`). Reads the SAME
`getWorkspaceContext` (`:57`). Holds "I Remember" / "Recent Decisions" / "Recent
Conversations" / suggested-prompt chips / stats bar. **It is not part of the Workspace
Desktop shell** — it's the chat empty-state. Useful as a copy/data reference for the
Overview's "What Waggle knows" rows but is not the screen being rebuilt. Its
collapsed-state persistence is `lib/workspace-briefing-state.ts` (tested — see §3).
---
## 2. Data contract — what's REAL vs mocked
The Overview's three data feeds come from real, populated endpoints (verified in
`packages/server/src/local/routes/workspaces.ts`). The DESIGN, however, surfaces several
fields the contract does NOT yet provide. Table below; "✔ real" = endpoint returns it
today, "✖ mock" = design shows it but no field exists, "~ derivable" = computable from
existing data.
### Endpoints + adapter methods (all real, no envelope)
| Adapter (`lib/adapter.ts`) | Route (`workspaces.ts`) | Returns |
|---|---|---|
| `getWorkspaceContext(id)` `:582` | `GET /:id/context` `workspaces.ts:364-655` | summary, recentMemories, recentDecisions, recentThreads, suggestedPrompts, `stats{memoryCount,sessionCount,fileCount}`, greeting, pendingTasks, workspace{type,status,description} |
| `getWorkspaceState(id)` `:601` | `GET /:id/state` `:663-693` | active/openQuestions/**pending**/**blocked**/completed/stale/recentDecisions/nextActions (WorkspaceStateView) |
| `getWorkspaceActivity(id,limit)` `:606` | `GET /:id/activity` `:699-742` | `{events:[{id,ts,type,actor?,summary}]}` from audit_events |
| `getWorkspaceFiles(id)` `:587` | `GET /:id/files` `:744-756` | `{files:[…]}` from file registry (interim "artifacts") |
| `getTeamMembers()` `:2160` | (global team roster) | `[{id,name,status,avatar?}]`**NOT workspace-scoped** (`WorkspaceDesktopApp.tsx:660-666` TODO) |
### Design field → reality
| Design element (workspace.html / SCREENS §03) | Source | Status |
|---|---|---|
| **Header meta: "142 memories"** | `ctx.stats.memoryCount` | ✔ real |
| **Header meta: "9 sources"** | — | ✖ mock (no `sourceCount`; harvest sources exist in mind but not exposed on context) |
| **Header meta: "1 agent live"** | `useRoomState().workspaceMap.get(id).live.length` (`:603-607`) | ✔ real (live SSE) |
| **Header meta: "updated 2h ago"** | `ctx.lastActive` (`:592,646`) | ~ derivable (have `lastActive`; relativeTime helper exists `:135-147`) |
| **Tab counts (Chat 3 · Memory 142 · Artifacts 7 · Files 12 · Team 4)** | memory=`stats.memoryCount` ✔; sessions=`stats.sessionCount` ✔; files=`stats.fileCount` ✔; team=`getTeamMembers().length` ✔(global); **artifacts** ✖ (no distinct artifact entity — interim = files) | ~ mostly derivable; artifacts count is the gap |
| **Left: summary card** | `ctx.summary` (`composeWorkspaceSummary` `workspaces.ts:31-75`) | ✔ real |
| **"What Waggle knows" fact rows** | `ctx.recentMemories` (content+importance+date) and/or `ctx.recentDecisions` | ✔ content real; **✖ per-fact provenance `⬡ source · when`** (frames have no `source` surfaced — design's "web · mem0.ai", "teardown.md" are mock; `date` IS present so "·when" is real) |
| **"Recent work" artifact rows (ext tile + name + provenance + time)** | `getWorkspaceFiles``normalizeArtifacts` (`:977-998`): name, mimeType/modifiedAt | ✔ name+time real; **✖ provenance source** ; ext-tile derivable from filename |
| **Right Status: agent live + name** | `useRoomState` live list — name not in the view-model today | ~ "live" real, agent NAME ✖ mock |
| **Right Status: model "auto · Claude Sonnet"** | `ctx.workspace.model` (`workspaces.ts:625`) | ✔ real (model id; "auto" + friendly name is display) |
| **Right Status: "+6 today"** | — | ✖ mock (no per-day memory delta on context; overnight delta exists on Home `OvernightSummary.consolidated` but not per-workspace) |
| **Right Status: "3 to review / Needs review"** | `HomeBriefing.needsReviewCount` exists at HOME scope (J08); per-workspace ✖ | ✖ mock at workspace scope |
| **Right "Up next" rows** | `state.pending` + `state.blocked` + `state.nextActions` + cron `upcomingSchedules` | ~ derivable (TasksWidget already uses pending/blocked; nextActions+schedules unused on this screen) |
| **Right Team avatar rows** | `getTeamMembers()` | ✔ real but **global roster, not per-workspace** (documented TODO `:660-662`) |
**Net:** the three load-bearing columns (summary, knowledge/memory facts, recent
work/artifacts, status counts, up-next, team) are all **backed by real endpoints**. The
**provenance `⬡ source · when` line** (a core trust pattern, README §6) is the single
biggest data gap — memory frames have a `source` column server-side
(`workspaces.ts:291` writes `source`, `:413` SELECTs around it) but it is **not
projected into `recentMemories`/`recentDecisions`** today, nor onto file rows. "9
sources", "+6 today", per-workspace "N to review", and a distinct **artifacts** entity
(vs files) are genuinely absent.
---
## 3. Test contract
What PR3 must NOT break (existing tests touching this surface):
- **`apps/web/src/test/p1a-workspace-route.test.tsx`** — pins the **route ↔ shell
contract**, NOT the visual layout. Asserts `WorkspaceRoute` calls
`selectWorkspace(routedId)` on deep-link / Back-Forward, skips when already active,
and **never** syncs the `local-default` placeholder (`:56-80`). It mocks
`WorkspaceDesktopApp` to a stub (`:30-32`) and `ChatHost`/`ChatSlot` (`:33-36`). The
route renders under `path="workspaces/:workspaceId/:tab?"` (`:44`). **Constraint:** the
`:tab?` param, the `selectWorkspace`-on-route effect, and the `local-default` guard
must survive any rewrite of `WorkspaceRoute`.
- **`apps/web/src/lib/workspace-briefing-state.test.ts`** — pins the per-workspace
briefing-collapsed localStorage helpers (key prefix `waggle:workspace-briefing-
collapsed:`, sanitisation, per-id isolation, no colon-boundary leak). Only relevant if
the briefing collapse behavior is carried into the new Overview; the helpers themselves
can be reused as-is.
- **Implicit `data-testid` contract** (consumed by live-smoke / Playwright + the empty
flows): `ws-desktop-root`, `ws-tab-bar`, `ws-tab-<id>`, `ws-tab-panel`,
`ws-overview-grid`, `ws-widget-{chat,artifacts,tasks,memory,activity}`,
`ws-info-panel`, `ws-status-pill`, `ws-members-stack`, `ws-agents-running`,
`ws-desktop-{loading,permission-denied,notfound,offline,retry}`, `ws-memory-tab`.
Changing the tab set (8→6) removes `ws-tab-{research,tasks,timeline,settings}` — grep
for those test ids before deleting (none appear in the two test files above, so the
risk is in untracked Playwright smokes, not unit tests).
There is **no test that pins the 8-tab set, the widget-grid layout, or the right-panel
contents** — so the Overview re-layout (grid → 2-col 1.7fr/1fr) and the tab reduction
are free to change as long as the route/shell contract and the load-bearing test ids are
preserved.
---
## 4. Design spec — Variation A (exact copy + structure)
From `SCREENS.md` §03 + `workspace.html` Variation A markup:
**Header** (`workspace.html:138-155`):
- **Breadcrumb** (`.crumbs`, mono 11.5px, `--text-dim`): `Home **Competitive
Intelligence**` (current workspace bold).
- **46px hex avatar** (`.wmark.hex`, 46×52, honey gradient `--honey-bright → --honey-deep`,
`#1a1407` glyph) showing the workspace initial.
- **H1** title (Hanken 650, 28px, `-0.02em`).
- **Meta row** (`.wmeta`, 13px `--text-muted`, gap 14px): `● 1 agent live` (live dot
`--healthy`) · `142 memories` · `9 sources` · `updated 2h ago`.
- **Header actions** (right): `Memory` (ghost button) + `Continue →` (honey primary).
**Tab bar** (`.tabs`, `workspace.html:157-164`): `Overview · Chat 3 · Memory 142 ·
Artifacts 7 · Files 12 · Team 4`. Active tab = `--text` + **2px honey bottom-border**
(`.tab.on`, `:44`). Each count is a mono 11px `--text-dim` `.cnt` span.
**Overview content** — 2-col grid `1.7fr / 1fr`, gap 22px (`.grid`, `:49`):
- **Left col:**
- **Summary card** (`.card`, `.summary` 15.5px/1.6, honey-bold keywords). Exact sample
copy: *"This workspace tracks the **persistent-memory competitor landscape** for the
Q2 board cycle. Waggle has mapped **9 rivals**, pulled current pricing, and drafted a
teardown — the live thread is mid-flight on turning the opening into a board brief."*
- **"What Waggle knows"** section (`.sec-h` mono uppercase label + brain icon, `:177`)
→ `.knows` list of `.fact` rows (`:61-67`): each = a **30px hex check tile**
(honey-gradient, `#1a1407` checkmark) + fact text (honey-bold spans) + a
**provenance line** `**source** · when` (`.prov`, mono 10.5px, `.src` =
`--intel`). Sample facts (`:279-284`): "Mem0 is cloud-only and raised prices ~15%…"
(`web · mem0.ai · 2h ago`), "Only 2 of 9 rivals ship local-first memory."
(`teardown.md · 2h ago`), "Mara wants the board brief to lead with the regulated-
industries opening." (`chat · Tue · 2d ago`), etc.
- **"Recent work"** section (file icon, `:182`) → `.arts` list of `.art` rows
(`:71-78`): **32px ext tile** (tinted by type color, mono ext label e.g. `MD`/`XLS`/
`PDF`) + name (`<b>`) + subtitle + right-aligned mono `.when` time. Samples (`:288-291`):
`teardown.md` "Q2 competitive teardown · 9 competitors" `2h ago`,
`pricing-landscape.xlsx`, `mem0-teardown.pdf`. Rows hover → `--honey-line` border.
- **Right col** (three `.card`s):
- **Status** (`:188-194`): `.stat-line` rows — `Agent` → `● Research-synth · live`
(`--healthy`); `Model` → `auto · Claude Sonnet`; `Memories` → `142 **+6 today**`
(delta `--healthy`); `Needs review` → `3 memories` (`--attention`).
- **Up next** (`:196-201`): `.agentrow`s — colored dot + label + mono status: `Board
brief from teardown` (draft, `--work`); `Export table → Salesforce` (awaiting you,
`--attention`); `Weekly digest` (`17:00`, `--healthy`).
- **Team** (`:203-206`): `.person` rows — 30px round avatar (initial, colored bg) +
name + role: `Mara K. · Owner`, `Research-synth · Agent · live`, `Deck-builder ·
Agent · idle`, `Jonas P. · Editor`.
**Non-Overview tabs** in this concept pass are a single empty placeholder (`.emptytab`,
`:211-216`): *"This tab is wired in the full prototype — Overview is the focus of this
concept pass."* — i.e. the design only fully specs Overview; the other tabs route to the
**real existing screens** (see §6).
**Memory stays a tab** (SCREENS §03 emphatic, README §4/§5 table). Variation B
(memory-forward knowledge-graph as the default view, `workspace.html:219-265`) is an
**alternate to defer**, NOT this PR.
---
## 5. Gap table (current → design)
| # | Area | Current | Design (Var A) | Severity |
|---|------|---------|----------------|----------|
| G1 | **Tab set** | 8 tabs: Overview/Chat/**Research**/Artifacts/Memory/**Tasks**/**Timeline**/**Settings** (`:68-77`) | 6 tabs: Overview/Chat/**Memory**/Artifacts/**Files**/**Team** | HIGH — drop Research/Tasks/Timeline/Settings from the bar; add Files + Team; reorder |
| G2 | **Tab counts** | none | per-tab mono count (Chat N·Memory N·Artifacts N·Files N·Team N) | MED — wire from `stats` + members; artifacts count gap |
| G3 | **Header avatar** | none | 46px hex avatar, honey gradient, workspace initial | MED — reuse `.hex` clip-path + initialsOf |
| G4 | **Breadcrumb** | none | `Home Workspace` mono crumb | LOW |
| G5 | **Header meta row** | type pill + status pill + "N running" | `● agent live · N memories · N sources · updated Xago` | HIGH — restyle to meta row; "sources" is a data gap (§2) |
| G6 | **Header actions** | WorkspaceActionsMenu (kebab) | `Memory` ghost + `Continue →` honey | MED — add the two buttons; KEEP the actions menu (real feature, not in mock) |
| G7 | **Overview layout** | 3-col equal-height widget grid | 2-col 1.7fr/1fr: left summary+knows+recent-work, right status+upnext+team | HIGH — full re-layout |
| G8 | **"What Waggle knows" rows** | `MemoryHighlightsWidget` (plain list, no provenance, no hex tile) | `.fact` rows: hex check tile + honey-bold text + `⬡ source · when` provenance | HIGH — new row component; **provenance source is a data gap** |
| G9 | **"Recent work" rows** | `ArtifactsWidget` (file icon + name) | `.art` rows: ext tile + name + subtitle + mono time + hover border | MED — restyle; ext-tile + provenance |
| G10 | **Status card** | scattered in right panel (counts + last activity) | one Status card: agent/model/memories+delta/needs-review | MED — consolidate; "+6 today" & "needs review" are data gaps |
| G11 | **Up next card** | none (tasks live in a widget + Tasks tab) | `.agentrow` list (draft/awaiting-you/scheduled) | MED — derivable from pending/blocked/nextActions/schedules |
| G12 | **Team card** | right-panel "Team members" (global roster) | Team card with role labels (Owner/Agent·live/Editor) | LOW — restyle; still global-roster-backed |
| G13 | **Right context panel** | persistent `w-72` aside (info/team/last-activity/quick-actions) | folded INTO the Overview right column; no separate aside | MED — the aside's content moves into the grid's right col |
| G14 | **Provenance pattern** | absent on this screen | `⬡ source · when` on every fact + artifact (core trust pattern) | HIGH (trust) — needs `source` projected from frames (§2) |
| G15 | **Tokens/typography** | shadcn `text-foreground/muted-foreground`, `font-display`, 10-12px dense | warm tokens, Hanken H1 28/650, 13-16px body, mono labels | MED — apply warm-Hive tokens (already shipped, §6) |
| G16 | **Memory tab** | `<MemoryCenterTab mind="workspace">` already embedded (`:937-941`) | Memory stays a tab | ✅ already correct — keep |
---
## 6. Reuse + build — tab bar → existing routes
The 6 design tabs map cleanly onto surfaces that **already exist**; PR3 rebuilds the
SHELL + Overview, and the other 5 tabs embed/deep-link the real screens:
| Design tab | Maps to | Exists? | How to wire |
|---|---|---|---|
| **Overview** | `WorkspaceDesktopApp` Overview canvas | ✔ (re-layout) | Rebuild as 2-col grid; this is the bulk of PR3 |
| **Chat** | `ChatSlot` (live per-workspace chat) | ✔ — `WorkspaceRoute.tsx:57` already portals `<ChatSlot workspaceId>` into the `chat` tab via the `chatSlot` seam (`WorkspaceDesktopApp.tsx:112,885`) | reuse as-is |
| **Memory** | `MemoryCenterTab` | ✔ — already embedded `WorkspaceDesktopApp.tsx:937-941` (`mind="workspace"`, `consumeDeepLinks={false}`) | reuse as-is (✅ G16) |
| **Artifacts** | `ArtifactsRoute`/Artifact Center | ✔ route exists (`routes/index.ts:36`); workspace tab currently inlines a file-card grid (`:914-932`) | embed the Artifact Center component, or keep the file grid until the real artifact entity lands (§2 gap) |
| **Files** | `FilesRoute` | ✔ route exists (`routes/index.ts:37`) | NEW tab — embed the Files surface scoped to the workspace; data via `getWorkspaceFiles` (already used) |
| **Team** | `TeamRoute` | ✔ route exists (`routes/index.ts:48`) | NEW tab — embed Team; or a workspace-scoped panel. NB roster is global today (§2) |
**Drop from the bar** (no longer top-level per design): **Research** (placeholder stub
only `:906-912`), **Tasks** (still a real `<TasksTab>` `:904` — relocate to the
Overview "Up next" card + keep reachable, don't delete the component), **Timeline**
(stub `:943-948` — lives at `/settings/timeline` per `routes/index.ts:29`), **Settings**
(stub `:951-957` — lives at `/settings`). Update `WS_TABS` in `WorkspaceRoute.tsx:20-22`
and the `WorkspaceTabId` union to the 6-tab set; unknown `:tab?` already falls back to
overview (`WorkspaceRoute.tsx:43-45`), so stale `/workspaces/:id/tasks` links degrade
gracefully.
**Reuse without change:**
- **Warm tokens already shipped** (PR1): `apps/web/src/index.css` defines `--honey`,
`--honey-wash`, `--honey-line`, `--honey-bright/-deep`, `--intel`, `--work`,
`--healthy`, `--bg`, `--surface*`, `--text*` (verified `:149-167`) + the shadcn HSL
core derives from them (`--primary` = honey `:29`). The Overview can use these
directly. `apps/web/src/waggle-theme.css` is the companion sheet.
- **Helpers in-file:** `initialsOf` (`:128-133`, for hex avatar + team), `relativeTime`
(`:135-147`, for "updated Xago" + artifact times), `normalizeArtifacts` (`:977-998`),
`humanizeActivitySummary` (`lib/activity-labels`).
- **Hex motif:** README §7 `.hex { clip-path: polygon(...) }` — add a Tailwind/utility
class for the 46px header avatar + the 30px fact check-tiles + 32px ext tiles.
- **Data plumbing:** the existing `useEffect` load (`:613-679`) already fetches context/
state/activity/members/files — **no new adapter calls needed** for Overview except
optionally projecting `source` onto memory rows (server change, §2/G14) and a per-
workspace artifacts/sources count (G2/G5).
- **`WorkspaceBriefing.tsx`** importance pills + "I Remember"/"Recent Decisions" copy
(`:181-199,164-178`) are a good reference for the "What Waggle knows" fact rows, but
the briefing itself stays the chat empty-state — don't fold it into the shell.
**Build new:** the 2-col Overview grid + `FactRow` (hex check tile + provenance),
`ArtRow` (ext tile + provenance + time), `StatusCard`, `UpNextCard`, `TeamCard`, the
46px hex header avatar + breadcrumb + meta row, and the `Memory`/`Continue →` header
buttons. Fold the current right `aside` content into the grid's right column (G13).

View File

@@ -0,0 +1,301 @@
# PR3.5 Recon — `frame.source` server read/write path (1-field projection)
**Goal:** add `source` to the `recentMemories` + `recentDecisions` items returned by the
workspace-state / workspace-context API so the FE can render a ⬡ provenance pill on Chat +
Workspace. This doc maps the exact write site, the exact read/SELECT/projection sites, the
type-contract change, and the column's value vocabulary.
Repo root: `D:/Projects/waggle-os`. All line numbers verified 2026-06-16.
---
## 0. TL;DR — what to change
The ⬡ provenance pill on the **Workspace Briefing** (Chat + Workspace Overview both render
`<WorkspaceBriefing>` fed by `GET /api/workspaces/:id/context`) is unlocked by **one route +
one type change**:
1. `packages/server/src/local/routes/workspaces.ts` — add `source` to the two SELECTs (lines
**393-400** and **409-417**) and to their two `.map()` projections (lines **402-406** and
**419-429**).
2. `apps/web/src/lib/types.ts` — widen `WorkspaceContext.recentMemories` / `recentDecisions`
item shapes (lines **248-249**) with an optional `source?: string`.
That is the minimum. There is a SECOND, parallel context builder in
`packages/server/src/local/routes/workspace-context.ts` (the `WorkspaceNowBlock` /
`buildWorkspaceState` path) that feeds the **system prompt**, NOT the FE pill — see §4 for why
it is out of scope, and the one ambiguity it raises.
---
## 1. WRITE path — where `frame.source` is set
### 1a. Schema DDL (the column itself)
`packages/hive-mind-core/src/mind/schema.ts:56-57`
```sql
source TEXT NOT NULL DEFAULT 'user_stated'
CHECK (source IN ('user_stated', 'tool_verified', 'agent_inferred', 'import', 'system')),
```
Column lives on `memory_frames` (DDL `CREATE TABLE ... memory_frames` at `schema.ts:47`).
Confirmed: the column name **is `source`** (not `frame_source` / `provenance`).
### 1b. Where rows are written with an explicit `source`
- **Template starter seeding** — `packages/server/src/local/routes/workspaces.ts:290-291`:
```sql
INSERT INTO memory_frames (frame_type, gop_id, t, content, importance, source)
VALUES ('I', ?, ?, ?, 'normal', 'system')
```
i.e. workspace template starter memories are written with `source = 'system'`.
- **General frame writes** go through `FrameStore` in
`packages/hive-mind-core/src/mind/frames.ts` — `createIFrame` /
`createPFrame` (`frames.ts:74,119`) default `source: FrameSource = 'user_stated'`.
- **MCP `save_memory`** maps an incoming `source` arg, defaulting to `'agent_inferred'`
(`packages/memory-mcp/src/tools/memory.ts:35`,
`packages/hive-mind-mcp-server/src/tools/memory.ts:34`).
So in practice persisted rows carry one of the five DB-CHECK values (see §3).
---
## 2. READ path — the SELECT + projection that feeds the FE (THE CHANGE SITE)
**Route:** `GET /api/workspaces/:id/context` —
`packages/server/src/local/routes/workspaces.ts:364` (handler).
This is the response consumed by `<WorkspaceBriefing>` (apps/web), which renders both the
"Key memories" list and the "Recent decisions" list on Chat + Workspace.
### 2a. `recentMemories` — SELECT at `workspaces.ts:393-400`, projection at `402-406`
**BEFORE** (SELECT, lines 393-400):
```ts
const frames = raw.prepare(
`SELECT content, importance, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
ORDER BY CASE importance
WHEN 'critical' THEN 1 WHEN 'important' THEN 2
WHEN 'normal' THEN 3 ELSE 4 END,
id DESC LIMIT 8`
).all() as Array<{ content: string; importance: string; created_at: string }>;
```
**AFTER**:
```ts
const frames = raw.prepare(
`SELECT content, importance, source, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
ORDER BY CASE importance
WHEN 'critical' THEN 1 WHEN 'important' THEN 2
WHEN 'normal' THEN 3 ELSE 4 END,
id DESC LIMIT 8`
).all() as Array<{ content: string; importance: string; source: string; created_at: string }>;
```
**BEFORE** (projection, lines 402-406):
```ts
recentMemories = frames.map(f => ({
content: f.content.slice(0, 200),
importance: f.importance,
date: f.created_at?.slice(0, 10) ?? 'unknown',
}));
```
**AFTER**:
```ts
recentMemories = frames.map(f => ({
content: f.content.slice(0, 200),
importance: f.importance,
source: f.source,
date: f.created_at?.slice(0, 10) ?? 'unknown',
}));
```
> The local `let recentMemories` is declared at `workspaces.ts:376` as
> `Array<{ content: string; importance: string; date: string }>` — add `source: string;`
> there too (or the `.map()` widening will type-error against the narrower local).
### 2b. `recentDecisions` — SELECT at `workspaces.ts:409-417`, projection at `419-429`
**BEFORE** (SELECT, lines 409-417):
```ts
const decisionFrames = raw.prepare(
`SELECT content, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
AND (content LIKE 'Decision%' OR content LIKE '%decided%'
OR content LIKE '%decision made%' OR content LIKE '%chose %'
OR content LIKE '%selected %' OR content LIKE '%agreed %'
OR importance = 'critical')
ORDER BY id DESC LIMIT 5`
).all() as Array<{ content: string; created_at: string }>;
```
**AFTER**:
```ts
const decisionFrames = raw.prepare(
`SELECT content, source, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
AND (content LIKE 'Decision%' OR content LIKE '%decided%'
OR content LIKE '%decision made%' OR content LIKE '%chose %'
OR content LIKE '%selected %' OR content LIKE '%agreed %'
OR importance = 'critical')
ORDER BY id DESC LIMIT 5`
).all() as Array<{ content: string; source: string; created_at: string }>;
```
**BEFORE** (projection, lines 419-429):
```ts
recentDecisions = decisionFrames.map(f => {
const firstLine = f.content.split('\n')[0];
const sentenceMatch = firstLine.match(/^(.+?\.\s)(?=[A-Z])/);
const text = sentenceMatch
? sentenceMatch[1].trim()
: (firstLine.length > 150 ? firstLine.slice(0, 147) + '...' : firstLine);
return {
content: text.replace(/\.\s*$/, ''),
date: f.created_at?.slice(0, 10) ?? 'unknown',
};
});
```
**AFTER** — add `source: f.source,` to the returned object:
```ts
return {
content: text.replace(/\.\s*$/, ''),
source: f.source,
date: f.created_at?.slice(0, 10) ?? 'unknown',
};
```
> Same as above: the local `let recentDecisions` declared at `workspaces.ts:377` as
> `Array<{ content: string; date: string }>` must gain `source: string;`.
### Response shape (unchanged structurally)
The handler returns an object whose `recentMemories` / `recentDecisions` fields are these two
arrays (assembled further down in the same handler, returned as part of the workspace-context
body). Adding `source` is purely additive — no existing consumer breaks.
---
## 3. TYPE-CONTRACT change
**File:** `apps/web/src/lib/types.ts` — interface `WorkspaceContext`, lines **248-249**:
**BEFORE**:
```ts
recentDecisions?: Array<{ content: string; date: string }>;
recentMemories?: Array<{ content: string; importance: string; date: string }>;
```
**AFTER**:
```ts
recentDecisions?: Array<{ content: string; source?: string; date: string }>;
recentMemories?: Array<{ content: string; importance: string; source?: string; date: string }>;
```
- Keep `source` **optional** (`source?:`) so older sidecars (pre-PR3.5) that don't emit the
field still typecheck and the FE degrades gracefully (no pill when absent).
- This is the ONLY type file that needs to change for the pill. `WorkspaceContext` is the FE
mirror; there is no separate `@waggle/shared` interface for these inline item shapes (they're
declared anonymously inline in both `workspaces.ts` and `types.ts`).
- The FE consumer is `apps/web/src/components/os/WorkspaceBriefing.tsx` — `recentMemories`
rendered at lines **186-197** (importance badge at 189-193, add the ⬡ pill alongside it),
`recentDecisions` rendered at lines **164-178**. No type change needed in the component; it
reads `m.content` / `m.importance` today and would add `m.source`.
> Note: the richer `@waggle/shared` `Memory` interface (`packages/shared/src/types.ts:517`,
> field `source: string` at `:526`, doc-comment "maps from `memory_frames.source`") already
> models provenance — but that's the Memory Center view-model, NOT the workspace-context item
> shape. Do not route the pill through `Memory`; the context items are their own inline type.
---
## 4. The PARALLEL builder (workspace-context.ts) — out of scope, but flagged
`packages/server/src/local/routes/workspace-context.ts` builds `WorkspaceNowBlock`
(`recentDecisions: string[]`, type at `:14-21`) for **system-prompt injection**, via
`buildWorkspaceState()` in `packages/server/src/local/workspace-state.ts:234`. Its decision
SELECT is `workspace-state.ts:86-94` (`SELECT id, content, created_at`) and the legacy inline
one is `workspace-context.ts:362` (`SELECT content`). Its items are typed `StateItem`
(`workspace-state.ts:30-36`) whose `source` field is a `StateSource =
'memory'|'session'|'awareness'` — that is a DIFFERENT `source` axis (where in the substrate the
item came from), **not** the `frame.source` provenance class.
**Decision:** the FE pill is fed by §2 (`workspaces.ts` `/context` route → `WorkspaceContext`
→ `WorkspaceBriefing`), so PR3.5's 1-field projection only needs §2 + §3. The
`workspace-state.ts` / `WorkspaceNowBlock` path does not surface to the pill and can be left
untouched. If a future task wants frame-provenance in the system prompt too, that's a separate,
larger change (it would collide with the existing `StateItem.source` name).
`home.ts` (`GET /api/home/briefing`) consumes `buildWorkspaceState().recentDecisions[0].content`
(`home.ts:314-315`) only — it reads `.content`, never `.source`, so it is unaffected.
---
## 5. SOURCE-column value vocabulary
**DB CHECK constraint (authoritative for persisted rows)** —
`schema.ts:57`:
| value | meaning |
|---|---|
| `user_stated` | user said it directly (FrameStore default) |
| `tool_verified` | confirmed by a tool execution |
| `agent_inferred` | agent inferred it (MCP `save_memory` default) |
| `import` | brought in via harvest/import |
| `system` | system-seeded (e.g. workspace template starter memory — `workspaces.ts:291`) |
**TS `FrameSource` union is WIDER than the DB CHECK** —
`packages/hive-mind-core/src/mind/frames.ts:25`:
```ts
export type FrameSource = 'user_stated' | 'tool_verified' | 'agent_inferred'
| 'import' | 'system' | 'personal' | 'workspace' | 'team_sync';
```
The extra three (`personal` / `workspace` / `team_sync`) are application-level labels that the
DB CHECK does **not** allow, so a constrained INSERT with one of them would fail — **persisted
rows can only ever hold the 5 CHECK values** (documented drift:
`docs/backend-map/sections/02a-data-model-memory.md:109` and `.../05b-subsystem-memory.md:318`).
**FE pill mapping guidance:** the pill should map the 5 real values to friendly labels/icons
(e.g. `user_stated`→"you", `tool_verified`→"verified", `agent_inferred`→"agent",
`import`→"imported", `system`→"system"). Treat anything else as a graceful fallback. Because
the projection returns the raw string, the FE owns the label map (do not hardcode it in the
route).
---
## 6. Honest ambiguities / risks
1. **Two SELECTs, one new column each** — straightforward, additive. The only typecheck trap is
the narrower `let recentMemories` / `let recentDecisions` declarations at `workspaces.ts:376-377`;
widen those too or `tsc` fails. (Reminder per CLAUDE.md §2: `npm run build` typechecks
`apps/web` only — run `npx tsc --noEmit --project packages/server/tsconfig.json` to catch a
server-route type error, since the sidecar runs via `tsx` transpile-only.)
2. **The "source" name is overloaded** — `frame.source` (provenance: 5 CHECK values) vs
`StateItem.source` (`memory|session|awareness`, where-it-came-from) vs `WorkspaceContext`
inline items (currently no `source`). Make sure the pill consumes the PROVENANCE one from §2,
not the `StateItem` one.
3. **Decision frames may carry any of the 5 sources** — the decision SELECT (`workspaces.ts:409`)
filters by `content LIKE` / `importance='critical'`, not by `source`, so a "decision" can be
`agent_inferred` or `user_stated` etc. That's fine for a pill; just don't assume decisions
are always `system`.
4. **No migration / no DDL change needed** — `source` already exists on every DB (NOT NULL
DEFAULT). The projection is read-only over an existing column. Zero data-backfill risk.

View File

@@ -0,0 +1,215 @@
# PR3.5 Recon — SSE/Chat "step" event path & provenance attach point
**Question:** Can a `source`/provenance field attach to streamed agent "step" events
(to power the `⬡ source · when` ProvenanceLine pill that PR3 wired date-only)?
**Verdict: NEEDS-WIRING.** A streamed `step` event today carries *only* `{ content: string }`
no frame id, no `source`, no provenance of any kind. The frame `source` *does* exist in the
substrate (`MemoryFrame.source: FrameSource`), but it is flattened to formatted text strings
before it ever reaches the SSE layer. Attaching `source` to a step is a real (but small &
well-scoped) plumbing job, not a trivial projection. The cleanest path is **not** to enrich the
generic `step` event — it's to enrich the `tool_result` event for the `auto_recall` (memory) tool,
which is the only step type that has a real provenance signal.
---
## 1. Server — where steps are produced & streamed
**Route:** `packages/server/src/local/routes/chat.ts``POST /api/chat`, SSE via `reply.hijack()`.
**SSE writer (the only emit helper):** `chat.ts:475`
```ts
const sendEvent = (event: string, data: unknown) => {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
```
### The `step` payload shape (server)
Every `step` event the server emits has the **same minimal shape**: `{ content: string }`. There is
no other field. Representative emit sites:
- `chat.ts:761``sendEvent('step', { content: 'Recalling relevant memories...' })`
- `chat.ts:780``sendEvent('step', { content: 'Recalled N relevant memories.' })`
- `chat.ts:1170``sendEvent('step', { content: stepText })` where `stepText = describeToolUse(name, input)`
(this is the **per-tool-call** step, fired from the agent-loop `onToolUse` callback — the main step source)
- plus budget/approval/compression/GEPA steps at `:740 :754 :771 :904 :921 :929 :1023 :1027 :1108 :1352 :1382 :1389 :1439 :1458` — all `{ content }` only.
### The agent-loop emission (where tool steps originate)
**`packages/agent/src/agent-loop.ts`** does **not** emit `step` events itself. It exposes typed
callbacks (`AgentLoopConfig`, `agent-loop.ts:37-39`):
```ts
onToken?: (token: string) => void;
onToolUse?: (name: string, input: Record<string, unknown>) => void;
onToolResult?: (name: string, input: Record<string, unknown>, result: string) => void;
```
The route wires these to SSE in the runner config (`chat.ts:1167-1185` / `:1186-1220`):
```ts
onToolUse: (name, input) => {
const stepText = describeToolUse(name, input);
sendEvent('step', { content: stepText }); // ← the step
sendEvent('tool', { name, input }); // ← raw tool event (has name+input)
...
},
onToolResult: (name, input, result) => {
...
sendEvent('tool_result', { name, result, duration, isError }); // ← name+result+duration
...
},
```
**Key fact:** the loop's callbacks expose `name`, `input`, `result` — but **no frame, no `source`,
no provenance**. The agent loop has no concept of which memory frame a step touched.
### The related `tool_result` shape (server)
`chat.ts:1200``{ name, result, duration, isError }`. For the memory tool specifically:
`chat.ts:781``sendEvent('tool_result', { name: 'auto_recall', result: resultText, duration, isError })`
where `resultText` is **already-rendered text** (`chat.ts:777-779`):
```ts
const snippets = (recall.recalled ?? []).slice(0, 3);
const snippetText = snippets.map(s => ` - ${s}`).join('\n');
const resultText = `${recall.count} memories recalled:\n${snippetText}`;
```
### Why the provenance is lost: `recallMemory()` returns text, not frames
`packages/agent/src/orchestrator.ts:453-457`:
```ts
async recallMemory(...): Promise<{ text: string; count: number; recalled?: string[] }>
```
`recalled` is a `string[]` of **formatted snippets** — the frame objects (which *do* carry
`source`) are collapsed to display strings inside `recallMemory` before returning. The real
provenance lives one layer deeper: `packages/hive-mind-core/src/mind/frames.ts:35`
`MemoryFrame.source: FrameSource` (e.g. `user_stated`, harvest adapters: chatgpt/claude/etc.),
plus `sourceUrl`/`sourceId` extras. So **the data exists in the DB, it is just not projected up
through `recallMemory` → SSE.**
---
## 2. Frontend — SSE consumer → activity stream blocks
### SSE parse + event-type mapping
**`apps/web/src/lib/adapter.ts:692-738`** (`streamChat` async generator) parses the raw SSE
text and maps event names to `StreamEvent.type`:
- `adapter.ts:729``else if (type === 'step') type = 'step';`
- yields `{ type, data } as StreamEvent` (`adapter.ts:732`) — `data` is the parsed `{ content }`.
### `StreamEvent` type
**`apps/web/src/lib/types.ts:616-619`**:
```ts
export interface StreamEvent {
type: 'token' | 'step' | 'tool_start' | 'tool_end' | 'done' | 'error' | 'approval_request' | 'approval_required' | 'model_switch' | 'notification';
data: unknown;
}
```
### Step → block reduction
**`apps/web/src/hooks/useChat.ts:152-165`** turns a `step` event into a `StepContentBlock`:
```ts
case 'step': {
const description = typeof data === 'string' ? data : (data?.content as string ?? '');
if (description) {
// mark prior running steps done
blocks.push({ type: 'step', blockId: nextBlockId('step'), description, status: 'running' });
}
break;
}
```
### `StepContentBlock` type (client) — **the field set to extend**
**`apps/web/src/lib/types.ts:474-479`**:
```ts
export interface StepContentBlock {
type: 'step';
blockId: string;
description: string;
status: 'running' | 'done';
}
```
No `source`/`provenance` field. This is the type that would gain an optional
`provenance?: { source: string; when?: string }`.
### Where provenance is (not yet) shown — PR3's date-only affordance
**`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:28-43`** groups a consecutive run
of step blocks into one `ActivityStream` card. PR3 already left the hook here and an honest comment
(`BlockRenderer.tsx:24-26`):
```ts
// Provenance pills are intentionally omitted: the SSE
// `step` payload carries no structured source field today (recon chat.md §4) —
// we render the affordance, never fabricated provenance.
```
And the map drops provenance (`BlockRenderer.tsx:30-33`):
```ts
const activitySteps: ActivityStep[] = steps.map(s => ({
tone: s.status === 'running' ? 'honey' : 'intel',
text: s.description, // ← no provenance projected
}));
```
The rendering target already exists and is wired:
- **`ActivityStream`** (`components/os/warm/ActivityStream.tsx:8-13`) — `ActivityStep.provenance?: { source; when?; onClick? }`, rendered at `:67-71` **only when present**.
- **`ProvenanceLine`** (`components/os/warm/ProvenanceLine.tsx:19-32`) — the `⬡ source · when` pill.
So the **client consumer is provenance-ready**; it is waiting for the server to deliver a `source`.
---
## 3. Honest verdict — trivial vs needs-wiring
**NEEDS-WIRING (small, scoped). NOT trivially projectable.**
- A generic `step` (e.g. "Drafting the document", "Budget limit reached", a `bash` tool call) has
**no provenance at all** and never will — those steps are not memory reads. Stamping them with a
`source` would be fabrication (exactly what PR3's comment refuses). So a blanket "add source to
every step" is wrong on the merits.
- The **one** step with a genuine provenance signal is the **memory recall** (`auto_recall`). Its
source is real (`MemoryFrame.source`) but is destroyed by `recallMemory()` returning `string[]`
text instead of frame metadata.
### The precise new wiring needed
1. **Substrate → orchestrator (the load-bearing change):** widen `recallMemory()`'s return in
`packages/agent/src/orchestrator.ts:453-457` to carry per-snippet provenance, e.g. add
`recalledFrames?: Array<{ text: string; source: FrameSource; sourceUrl?: string; when?: string }>`
alongside the existing `recalled: string[]`. The frame objects with `.source` are already in hand
inside `recallMemory` — this is a "stop flattening it" change, not a new query. (This is the
*same* 1-field server projection the S2 handoff named: *"the `frame.source` 1-field server
projection (unlocks the ⬡ provenance pill on Chat+Workspace)"*.)
2. **Server SSE:** in `chat.ts` around `:780-781`, emit the source on the memory step/tool_result —
either add `source`/`provenance` to the existing `tool_result` (`{ name:'auto_recall', ... }`) or
to the `step` payload for that one event: `sendEvent('step', { content, provenance: { source, when } })`.
(`{ content }``{ content, provenance? }` is additive and backward-compatible.)
3. **Client types:** add optional `provenance?: { source: string; when?: string }` to
`StepContentBlock` (`types.ts:474`) and to the `StreamEvent` `data` handling.
4. **Client reducer:** in `useChat.ts:152` carry `data.provenance` onto the pushed step block.
5. **Client render:** in `BlockRenderer.tsx:30-33` project `s.provenance` into `ActivityStep` (the
`ActivityStream`/`ProvenanceLine` rendering path already exists and gates on presence).
**Effort estimate:** ~5 small edits across 4 files (orchestrator return-shape widen is the only
non-trivial one; everything else is a 1-field pass-through). No new DB columns, no new query — the
`source` already exists at `frames.ts:35`. Lowest-risk slice = wire it on `auto_recall` only
(memory steps), leave all other steps provenance-less by design.
### Scope call for PR3.5
**In-scope and the right size for PR3.5** *iff* paired with the `recallMemory` return-shape widen.
If PR3.5 wants to stay frontend-only, then provenance on steps is **out-of-scope** (the data is not
on the wire) — and the honest move is to keep PR3's "render the affordance, never fabricated
provenance" stance until the server projection lands.
---
## Appendix — file:line index
| Concern | File:line |
|---|---|
| SSE writer | `packages/server/src/local/routes/chat.ts:475` |
| step payload (memory) | `chat.ts:761`, `:780` |
| step payload (per tool call) | `chat.ts:1170` (via `describeToolUse`) |
| tool_result payload | `chat.ts:1200`; memory variant `chat.ts:781` |
| recall snippet build (text flatten) | `chat.ts:777-779` |
| agent-loop callbacks (no source) | `packages/agent/src/agent-loop.ts:37-39`, wired `chat.ts:1167-1220` |
| recallMemory return shape | `packages/agent/src/orchestrator.ts:453-457` |
| frame `source` field (real provenance) | `packages/hive-mind-core/src/mind/frames.ts:35` |
| SSE parse + map | `apps/web/src/lib/adapter.ts:717-732` |
| StreamEvent type | `apps/web/src/lib/types.ts:616-619` |
| step → block reducer | `apps/web/src/hooks/useChat.ts:152-165` |
| StepContentBlock type | `apps/web/src/lib/types.ts:474-479` |
| activity grouping + omitted-provenance comment | `apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:24-43` |
| ActivityStream (provenance-ready) | `apps/web/src/components/os/warm/ActivityStream.tsx:8-13, 67-71` |
| ProvenanceLine pill | `apps/web/src/components/os/warm/ProvenanceLine.tsx:19-32` |

View File

@@ -0,0 +1,155 @@
# PR3.5 Recon — What PR3 actually left behind (hooks + primitives)
> Audit, not trust. Every row below was verified against source on branch
> `feature/warm-hive-pr3` @ `dac7b696`. Where the handoff was imprecise it is
> called out. Repo root: `D:/Projects/waggle-os`.
---
## TL;DR — claimed primitives: exist vs must-build
**The three "Memory-Trust primitives" the handoff named — `ConfidenceBadge`,
`EvidenceChip`, `DetailDrawer` — ALL EXIST and are already in production use.**
The handoff's only error was the *location*: they are NOT in `os/warm/`, they live
in `components/ui/` and predate PR3 (created in the earlier UX-refactor arc, commit
`afe96355` "Phase 2B-FE.1 — Memory Center DS foundation"). PR3 added the warm
provenance layer (`ProvenanceLine`, `ActivityStream`) on top.
So PR3.5 **reuses**, does not build-from-scratch, the trust primitives. What is
genuinely **missing / static** is the *wiring*: the Workspace "What Waggle knows"
fact rows and the Chat activity step rows are visual-only (no `onClick`, no
provenance plumbed), and the chat `StepContentBlock` type has no `source` field —
that is the real PR3.5 work (the "frame.source 1-field server projection" keystone).
---
## TRUTH TABLE — claim → real state → file:line
| # | Handoff claim | Real state | Evidence (file:line) |
|---|---|---|---|
| 1 | "ConfidenceBadge / EvidenceChip / DetailDrawer primitives **ready** [implied: in `warm/`]" | **TRUE but mislocated.** All three exist in `components/ui/`, NOT `os/warm/`. Pre-date PR3 (commit `afe96355`). Already consumed by Memory Center. Do NOT rebuild. | `apps/web/src/components/ui/confidence-badge.tsx:22` (export `ConfidenceBadge`)<br>`apps/web/src/components/ui/evidence-chip.tsx:16` (export `EvidenceChip`)<br>`apps/web/src/components/ui/detail-drawer.tsx:22` (export `DetailDrawer`)<br>Bonus: `apps/web/src/components/ui/evidence-panel.tsx` (`EvidencePanel`) also exists |
| 1b | (warm/ dir listing) | The PR3 `warm/` set is 14 atoms; the trust primitives are NOT among them. warm/ has the provenance *renderer* (`ProvenanceLine`) + the chat *container* (`ActivityStream`). | `apps/web/src/components/os/warm/index.ts:7-20` |
| 2a | "clickable fact rows" (Workspace "What Waggle knows", PR3 Phase C2) | **FALSE — STATIC.** The `<li>` rows render `HexCheckTile + text + when`. No `onClick`, no anchor, no role=button, no cursor affordance. Pure display. | `apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx:160-168` (the `facts.map` `<li>` has no handler). Header comment at `:146-149` explicitly flags `frame.source` is not yet projected onto these rows ("the PR3.5 keystone"). |
| 2b | "clickable step rows" (Chat activity steps) | **FALSE — STATIC.** `ActivityStream` renders each step as a `<li>` (`DotLive + text + optional ProvenanceLine`). The step `<li>` itself has no `onClick`. Provenance is only rendered `if (s.provenance)` — and the Chat caller never supplies it. | `apps/web/src/components/os/warm/ActivityStream.tsx:62-74` (step `<li>` static; provenance gated on `s.provenance`)<br>`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:30-33` (maps only `tone`+`text`**omits `provenance` entirely**) |
| 2c | (why chat steps can't show provenance yet) | **Root blocker.** `StepContentBlock` has no `source`/`provenance` field. So even if BlockRenderer wanted to pass provenance, the data isn't on the block. Same shape as the Workspace keystone — both need `frame.source` server projection. | `apps/web/src/lib/types.ts:474-479` (`StepContentBlock` = `{type,blockId,description,status}` — no source) |
| 3 | "intact J08 banner" | **TRUE.** Renders on Home Cockpit; gated `needsReviewCount > 0`; `role="alert"`; deep-links to Memory Center via `waggle:open-app` with `filter:'unreviewed'`. Wiring intact end-to-end (route re-stash in `MemoryRoute.tsx`, consumed by `MemoryCenterTab`). | Render: `apps/web/src/components/os/apps/HomeCockpit.tsx:496-515`<br>Handler `openMemoryReview`: `:477-481`<br>Count source: `briefing.needsReviewCount` (`lib/types.ts:303`)<br>Consumer: `MemoryCenterTab.tsx:84` |
| 4 | "ProvenanceLine.tsx" | **EXISTS** (PR3 Phase 0, commit `0b15b91f`). Thin recolor of `EvidenceChip` to the intel/violet semantic. Full API below. | `apps/web/src/components/os/warm/ProvenanceLine.tsx:19` |
| 5 | "warm/index.ts + tones.ts" | **EXIST.** Full exports + tone vocab below. | `apps/web/src/components/os/warm/index.ts`, `tones.ts` |
---
## ProvenanceLine — full API (verified, `ProvenanceLine.tsx:4-32`)
```ts
interface ProvenanceLineProps {
source: string; // REQUIRED. e.g. "web · mem0.ai" or "Claude Code"
when?: string; // optional relative time, e.g. "2h ago"
onClick?: () => void; // PR3.5 trace hook — makes the pill clickable into memory detail
className?: string;
}
export function ProvenanceLine({ source, when, onClick, className }): JSX.Element
```
**Render:** builds `label = \`⬡ ${source}${when ? \` · ${when}\` : ''}\``, then
delegates to `<EvidenceChip label title={label} onClick className=...>`. The chip is
recolored via `font-mono text-[var(--intel)] border-[var(--intel-wash)] bg-transparent`.
- **Clickable behavior:** when `onClick` is supplied, `EvidenceChip` renders a real
`<button type="button">` with `hover:bg-muted hover:text-foreground` (`evidence-chip.tsx:19-25`);
without it, a static `<span>` (`:26-30`). So `ProvenanceLine` is click-ready **iff the
caller passes `onClick`** — the primitive supports it; the call sites don't use it yet.
- **`source` absent → date-only:** `source` is a **required** prop, so the pill cannot
be rendered "date-only" via ProvenanceLine. The actual date-only fallback today is done
by **not rendering ProvenanceLine at all** and showing a plain mono date instead — see
the Workspace fact rows (`WorkspaceDesktopApp.tsx:165`, a bare
`<div class="font-mono text-[10.5px]">{f.when}</div>`). The deliberate design note: PR3
shows the REAL date only and **never fabricates a `source`** until `frame.source` is
projected (`WorkspaceDesktopApp.tsx:146-149`). **PR3.5 implication:** to light up the
⬡ pill, project `frame.source` server-side, then swap the bare date `<div>` for
`<ProvenanceLine source={...} when={f.when} onClick={...} />`.
---
## Tone / token vocabulary (for new Memory-Trust primitives)
### `os/warm/tones.ts` — `WarmTone` union + maps (`tones.ts:7-36`)
```ts
type WarmTone = 'work' | 'intel' | 'healthy' | 'attention' | 'risk' | 'honey' | 'neutral';
TONE_COLOR: Record<WarmTone, string> // foreground/dot color
work→var(--work) intel→var(--intel) healthy→var(--healthy)
attention→var(--attention) risk→var(--risk) honey→var(--honey)
neutral→var(--text-muted)
TONE_WASH: Record<WarmTone, string> // tinted background
work→var(--work-wash) intel→var(--intel-wash) healthy→var(--healthy-wash)
attention→var(--honey-wash) risk→var(--risk-wash) honey→var(--honey-wash)
neutral→var(--surface-3)
```
> Note the asymmetry: `attention` foreground = `--attention`, but its wash = `--honey-wash`
> (not `--attention-wash`). Match this when building a trust primitive on the attention tone.
### CSS tokens — defined in BOTH themes (`apps/web/src/index.css`)
| Token | Dark (`:162-168`) | Light (`:298-304`) | Semantic |
|---|---|---|---|
| `--intel` | `#b196dd` | `#7d57b8` | **violet — intelligence / memory / provenance** (the trust color) |
| `--intel-wash` | `rgba(177,150,221,.12)` | `rgba(125,87,184,.10)` | violet wash |
| `--work` / `--work-wash` | desaturated blue | — | task/work |
| `--healthy` / `--healthy-wash` | desaturated green | — | OK/done |
| `--attention` | (status block `:102+`) | — | needs-attention (uses `--honey-wash`) |
| `--risk` / `--risk-wash` | — | — | blocked/conflict |
| `--honey` / `--honey-wash` | — | — | brand accent |
The `confidence-badge` uses a *different* token family — Hive DS `--sem-*`
(`--sem-healthy` / `--sem-attention` / `--sem-risk`, `confidence-badge.tsx:17-19`) — not
the warm `--intel/--work/...`. **PR3.5 consistency call:** decide whether new
Memory-Trust atoms align to the warm `--intel` family (provenance) or the `--sem-*`
family (confidence bands). They currently coexist.
---
## warm/index.ts — full export list (`index.ts:7-20`)
```
HexAvatar · SectionLabel · DotLive · ProvenanceLine · RunChip (+RunChipProps)
IconTile · HexCheckTile · StreakChip · ModelPill · OvernightHero · AskBar
ActivityStream (+ActivityStep) · InlineApprovalCard · TONE_COLOR · TONE_WASH (+WarmTone)
```
14 atoms. **Not present (and not the warm dir's job per its header doc, `index.ts:3-6`):**
ConfidenceBadge / EvidenceChip / DetailDrawer — "Generic, always-labeled primitives
stay in `components/ui/`."
---
## Where the Memory surfaces live today (screen 19 target map)
| Surface | File | Notes for PR3.5 |
|---|---|---|
| **Memory Center (standalone screen, S04)** | `apps/web/src/components/os/apps/MemoryCenterApp.tsx` | The full screen. **Two-mind split** ("About you" = personal, "About this work" = workspace) + 7 views: `memories · timeline · graph · harvest · weaver · wiki · evolution` (`MEMORY_VIEWS`, `:36-38`). Controlled component (route owns mind+view via URL). This is what **screen 19 extends or replaces.** |
| **Per-mind memory list (the reusable core)** | `apps/web/src/components/os/apps/memory/MemoryCenterTab.tsx` | Parameterized by `mind` + `workspaceId`. **Already consumes the trust primitives:** `DetailDrawer` (`:337`), `ConfidenceBadge` (`:342`), `EvidencePanel`+source/evidence (`:406`), `MemoryCard` grid (`:323-331`, each card `onClick={() => openDetail(m)}`). This is the proven pattern PR3.5 should match. |
| **Workspace Memory tab** | `apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx:701-705` | Tab `id:'memory'` (`TABS`, `:73`) embeds the SAME `<MemoryCenterTab mind="workspace" workspaceId consumeDeepLinks={false} />`. So the Workspace memory tab and the Memory Center already share one component — change once, both update. |
| **Route wrapper** | `apps/web/src/routes/MemoryRoute.tsx` | `/memory` ≡ `/memory/personal`; J08 banner target; re-stashes `?filter=` deep link. |
| **Memory card row** | `apps/web/src/components/os/apps/memory/MemoryCard.tsx` | Individual memory row component (grep-confirmed it references ConfidenceBadge family). |
| **J08 needs-review banner** | `apps/web/src/components/os/apps/HomeCockpit.tsx:496-515` | Already wired; deep-links here. |
| **Import reminder banner** | `apps/web/src/components/os/apps/memory/ImportReminderBanner.tsx` | Secondary banner on the Memory Center. |
---
## PR3.5 build implications (derived from the truth table)
1. **Reuse, don't rebuild** the trust primitives (`ui/confidence-badge`,
`ui/evidence-chip`, `ui/detail-drawer`, `ui/evidence-panel`) — they exist and ship today.
2. **The keystone is the data, not the UI:** project `frame.source` (+ `when`) server-side
so the Workspace fact rows and Chat steps can carry real provenance.
3. **Make the rows clickable** by (a) adding `onClick` to the Workspace fact `<li>`
(`WorkspaceDesktopApp.tsx:160`) → open the memory detail drawer, (b) extending
`StepContentBlock` (`types.ts:474`) with an optional `source` and threading it through
`BlockRenderer.tsx:30-33` into `ActivityStep.provenance`.
4. **`ProvenanceLine` already supports `onClick`** (→ `EvidenceChip` renders a `<button>`);
the only gap is call sites passing the handler + a real `source`.
5. **Token consistency decision** pending: warm `--intel` family (provenance) vs Hive DS
`--sem-*` family (confidence). Both live; pick one story for screen 19.

View File

@@ -0,0 +1,317 @@
# Screen 19 — Memory Trust · Build-Ready Design Contract
> Source of truth: `docs/design_handoff_waggle_app/design-files/screens/memory-trust.html`
> (authoritative mock, read in full), `screenshots/19-memory-trust.png` (rendered),
> `DESIGN_POV.md` #1, `SCREENS.md` (no 0118 entry — this screen is POV-driven, #19).
> All copy below is **verbatim** from the mock. All class names / colors / spacing are
> quoted from the HTML `<style>` block.
**DESIGN_POV #1 mandate (verbatim, `DESIGN_POV.md:12-34`):** "A persistent-memory
product's #1 churn driver isn't *forgetting* — it's **remembering the wrong thing**…"
The designed response is a *Memory Trust* layer with four primitives: **confidence +
freshness** on every memory; **Forget** (real removal from recall) and **Correct**
(inline; dependents re-checked); **Stale review** prompts; **"Why did you do that?"
trace**. It is "also a moat… auditability, right-to-correct, EU AI Act alignment."
POV §How-to-use: "wire it to the real memory store (confidence/freshness/forget/correct/
trace are all backed by data the substrate already has or can derive)."
---
## 1. Header / Views (the segmented control)
The page is a single full-height column (`body { height:100vh; overflow:hidden;
display:flex; flex-direction:column }`). A sticky control bar (`.controls`, blurred
`backdrop-filter:blur(10px)`, `border-bottom:1px solid var(--line-soft)`) holds a
**2-button segmented switch** that toggles between two full views.
| Element | Verbatim copy / spec |
|---|---|
| Eyebrow label (`.lab`, mono 10.5px, `--text-dim`, uppercase) | `Memory Trust · view` |
| Segment button 1 (`.seg button.on`**active by default**) | `Manage memory` |
| Segment button 2 (`.seg button`) | `Why did you do that?` |
| Active-view label (`.vlabel`, swaps on toggle) — Manage | `<b>Manage</b> — forget, correct, confirm; see confidence & freshness` |
| Active-view label — Why | `<b>Why-trace</b> — every action explains itself` |
| Right control (`.tbtn`) | Theme toggle `☾`/`☀` |
**Segment active state:** `.seg button.on { background:var(--honey); color:#1a1407; }`
(honey fill + dark-ink text — the canonical "selected" treatment). Inactive buttons are
`background:transparent; color:var(--text-muted)`. Segment container `.seg` is a
`var(--surface-2)` pill with `border:1px solid var(--line-soft)`, `border-radius:10px`,
3px inner padding.
**Two VIEW modes:**
- **A — "Manage memory"** (`.view[data-view="manage"]`, on by default — this is what the
screenshot shows): editorial hero → 4 stat cards → search+filters → memory rows → trust
principle footnote.
- **B — "Why did you do that?"** (`.view[data-view="why"]`): editorial hero → a single
**trace card** (goal→recall→checks→action chain) → trust principle footnote. (See §6.)
Toggle JS: clicking a segment button sets `.on`, shows the matching `.view`, swaps the
`.vlabel` HTML, and resets `.stage` scroll to 0. Stage content is centered in
`.wrap { max-width:920px; margin:0 auto; padding:30px 32px 70px }`.
---
## 2. Editorial Hero
### View A — Manage (`.head`, margin-bottom 22px)
- **Eyebrow** (`.eyebrow`, mono 11px, `letter-spacing:.14em`, uppercase, `color:var(--honey)`,
with a 20px honey rule `::before`): `Trust · the thing that makes you stay`
- **H1** (`.head h1`, 28px / weight 650 / `letter-spacing:-0.02em`; `<em>` is **honey,
non-italic** — `h1 em { font-style:normal; color:var(--honey) }`):
`Memory you can ` + **`correct, age, and forget.`** ← the phrase **"correct, age, and
forget."** gets the honey accent (everything after "Memory you can " is in `<em>`).
- **Body** (`.head p`, 15px, `--text-muted`, `line-height:1.55`, `max-width:64ch`; `<b>` =
`--text-2`): verbatim —
> A memory that only grows is a liability. Waggle shows you **how sure it is**, **how
> fresh it is**, and **where it came from** — and lets you fix or forget anything.
> You're always in control of what the hive believes.
(Honey-emphasis `<b>` spans: "how sure it is", "how fresh it is", "where it came from".)
### View B — Why
- **Eyebrow:** `Provenance · accountability`
- **H1** (honey `<em>` = the quoted question):
`Ask the agent ` + **`"why did you do that?"`**
- **Body** (`<b>` spans bolded): verbatim —
> Any action an agent takes can be traced back to the exact memories and sources behind
> it — so a wrong move is **diagnosable, not mysterious**. If a bad memory caused it,
> fix the memory right from the trace.
---
## 3. Stat Cards — "trust summary bar" (`.tsum`, View A only)
4-up grid: `grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:24px`. Collapses
to `1fr 1fr` under `max-width:820px`. Each card `.ts`: `padding:16px`, `border-radius:
var(--r-lg)` (18px), `border:1px solid var(--line-soft)`, `background:var(--surface)`.
Value `.ts .v` = 24px / weight 750 / `letter-spacing:-0.02em`; label `.ts .l` = 11.5px,
`--text-muted`, `margin-top:5px`. **Warn variant** `.ts.warn`: `border-color:
color-mix(in srgb,var(--attention) 35%,transparent)` + value tinted `--attention`.
| # | Value (verbatim) | Label (verbatim) | Color of value | Card variant |
|---|---|---|---|---|
| 1 | `142` | `Memories in this hive` | default `--text` (white/ink) | `.ts` |
| 2 | `128` | `High confidence & fresh` | **green**`style="color:var(--healthy)"` (`#6cb78c`) | `.ts` |
| 3 | `9` | `Stale · worth a review` | **honey**`--attention` (`#e9a52c`) | `.ts.warn` |
| 4 | `3` | `Awaiting your confirm` | **honey**`--attention` (`#e9a52c`) | `.ts.warn` |
Color order = **white / green / honey / honey** (matches the brief). Cards 3 & 4 also get
the honey-tinted warn border.
---
## 4. Search + Filters (`.toolbar`, View A only)
`.toolbar { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap }`.
**Search bar** (`.search` — flex:1, min-width:200px, `padding:9px 14px`, `border-radius:
11px`, `border:1px solid var(--line)`, `background:var(--surface)`):
- Leading magnifier `<svg>` (circle + handle), 16px, `stroke:var(--text-dim)`,
`stroke-width:1.9`, no fill.
- Input placeholder (verbatim): `Search what Waggle knows… or ask it to forget something`
— 14px, transparent bg, `color:var(--text)`.
**Filter chips** (`.filt`, 12.5px / weight 600, `padding:8px 13px`, `border-radius:9px`,
`border:1px solid var(--line-soft)`, `background:var(--surface)`):
| Chip (verbatim) | State |
|---|---|
| `All` | **active**`.filt.on` |
| `Stale` | default |
| `Needs confirm` | default |
| `Forgotten` | default |
**Active chip** `.filt.on`: `color:var(--text); border-color:var(--honey-line);
background:var(--honey-wash)` (honey-wash fill + honey-line border). Hover `.filt:hover`
just lifts `color` to `--text`. (Note: the warm primitive set already has chip-like
patterns; the active = honey-wash + honey-line treatment must be reused.)
---
## 5. Memory ROW anatomy (`.mem`, the core component — repeated in `.mems` grid)
`.mems { display:grid; gap:10px }`. Each row `.mem`: `border:1px solid var(--line-soft)`,
`background:var(--surface)`, `border-radius:var(--r-lg)` (18px), `padding:16px 18px`,
`transition:.15s`. **State border variants:**
- `.mem.stale``border-color:color-mix(in srgb,var(--attention) 30%,var(--line-soft))`
- `.mem.disputed` → `border-color:color-mix(in srgb,var(--risk) 30%,var(--line-soft));
opacity:.92`
Layout `.mem .top { display:flex; align-items:flex-start; gap:13px }` — three columns:
**[confidence ring] [body] [actions]**.
### 5a. Confidence ring (`.conf`, width 42px, flex:none, centered)
- Ring `.ring`: `width/height 38px`, `border-radius:999px`, `display:grid; place-items:
center`, mono 11px / weight 600. **Both the number color AND the 2px ring border come
from `confColor(c)`** (inline `style="color:${confColor(c)};border:2px solid
${confColor(c)}"`).
- **Ring color logic** (`confColor(c)` JS, verbatim):
- `c >= 85` → `var(--healthy)` (sage green)
- `c >= 60` → `var(--attention)` (honey)
- `else` (`< 60`) → `var(--risk)` (terracotta)
- Caption `.cl` below ring: literal text `conf` (rendered uppercase via
`text-transform:uppercase`), 9px mono, `--text-dim`, `margin-top:4px`,
`letter-spacing:.06em`. → reads **`CONF`**.
### 5b. Body (`.mbody`, flex:1)
- **Fact text** `.mtext`: 14.5px, `line-height:1.5`, `color:var(--text)`. **`<b>` inside
gets weight 650** — this is the honey-free *bold* emphasis on the key phrase (NOT
honey-colored; it's bold weight only). Disputed rows strike through: `.mem.disputed
.mtext { text-decoration:line-through; text-decoration-color:color-mix(in srgb,
var(--risk) 60%,transparent); color:var(--text-muted) }`.
- **Provenance line** `.prov` (`margin-top:8px`, flex-wrap, `gap:7px 14px`, mono 10.5px,
`color:var(--text-dim)`). Format = three segments:
`⬡ <id>` · `source: <src>` · `● <freshness>`
- ID segment: `<span class="src">⬡ ${m.id}</span>` — `.prov .src { color:var(--intel) }`
(muted violet) — e.g. `⬡ M-204`.
- Source segment: plain `source: ${m.src}` in `--text-dim` — e.g. `source: chat · Tue`.
- Freshness segment: `` + label, colored by `.fresh.ok`→`var(--healthy)` (green) or
`.fresh.old`→`var(--attention)` (honey). Fresh label = `fresh`; old label =
`aging — last seen 6w ago`.
### 5c. Action buttons (`.acts`, flex:none, gap:6px)
Two square icon buttons `.mact` (30×30px, `border-radius:8px`, `border:1px solid
var(--line-soft)`, `background:var(--surface-2)`, `color:var(--text-muted)`; icon svg 15px
`stroke:currentColor` `stroke-width:1.8`):
1. **Edit / correct** — `title="Edit / correct"`, `data-act="edit"`; pencil icon. Hover
`.mact:hover { border-color:var(--honey-line); color:var(--honey) }`. Click → toast
`Correcting <id> — opens an inline editor`.
2. **Forget / delete** — `.mact.danger`, `title="Forget this"`, `data-act="forget"`; trash
icon. Danger hover `.mact.danger:hover { border-color:color-mix(in srgb,var(--risk)
45%,transparent); color:var(--risk) }`. Click → row animates out (`opacity:0;
translateX(-12px)`, 300ms, then removed) + toast `Forgotten <id> — removed from recall`.
### 5d. Inline sub-banners (conditional, render inside `.mbody`)
- **Corrected banner** (`.corrected`, disputed rows that carry `m.corrected`): healthy-wash
pill — `background:var(--healthy-wash); border:1px solid color-mix(in srgb,var(--healthy)
30%,transparent)`, 12.5px `--text-2`, green check svg (`stroke:var(--healthy)`). Renders
`<b>{before "→"}</b> → {after "→"}`.
- **Stale review banner** (`.stalebanner`, on `.mem.stale`): honey-wash pill —
`background:var(--honey-wash); border:1px solid var(--honey-line)`, clock svg
(`stroke:var(--honey)`). Text (verbatim): `This is 6 weeks old — still true?` Right-aligned
button pair `.sp`:
- `.sbtn.go` (`background:var(--honey); color:#1a1407`) — verbatim `Still true`
(`data-act="confirm"`). Click → banner morphs into a `.corrected` "Confirmed still true ·
freshness reset" pill + toast `Confirmed <id> — freshness reset`.
- `.sbtn.ghost` (`background:var(--surface); color:var(--text-2); border:var(--line-strong)`)
— verbatim `Forget` (`data-act="forget"`).
### 5e. Seed dataset (the 5 demo rows — verbatim `mems[]`)
| id | fact text (`<b>` = bold phrase) | conf | ring color | src | fresh | state |
|---|---|---|---|---|---|---|
| `M-204` | Mara wants market work to **lead with the regulated-industries angle**. | 94 | green | `chat · Tue` | fresh | ok |
| `M-198` | **Mem0** is cloud-only and raised prices ~15% in March. | 88 | green | `web · mem0.ai` | fresh | ok |
| `M-141` | The Q3 launch date is **September 12**. | 61 | honey | `chat · 6 weeks ago` | old | **stale** (shows stale banner) |
| `M-088` | Mara prefers **Slack over email** for updates. | 47 | terracotta | `inferred · once` | old | **disputed** (strikethrough + corrected banner: `Corrected by you → prefers a daily digest, not Slack pings.`) |
| `M-052` | Primary competitor is **Letta**. | 90 | green | `teardown.md` | fresh | ok |
---
## 6. "Why did you do that?" Trace view (View B, `.trace`)
A single card: `border:1px solid var(--line)`, `border-radius:var(--r-xl)` (26px),
`background:var(--bg-2)`, `overflow:hidden`. Three regions: **header → vertical chain →
action footer**, then the trust principle footnote below.
### 6a. Trace header (`.th`, `padding:18px 22px`, bottom border)
- **Hex avatar** `.ti.hex` (34×38px, honey gradient `linear-gradient(150deg,
var(--honey-bright),var(--honey-deep))`, dark-ink `paper-plane`/send svg). → reuse warm
`HexAvatar` primitive.
- Title `<b>` 15.5px/650 (verbatim): `Drafted the board brief around "regulated industries"`
- Subtitle `.sub` 12px `--text-muted` (verbatim): `Deck-builder · 18m ago · Q2 Board Deck`
- `.when` (mono 11px `--text-dim`, margin-left:auto): `trace #a1f9`
### 6b. Trace chain (`.tchain`, `padding:8px 22px 18px`) — `.tnode` steps
Each node is a 2-col grid `24px 1fr` with a **vertical connector line** drawn via
`.tnode::before { position:absolute; left:11px; top:34px; bottom:-14px; width:1.5px;
background:var(--line) }` (suppressed on `:last-child`). Each `.tdot` (24px circle,
colored per step, dark-ink svg `stroke:#1a1407 stroke-width:2.4`):
| # | dot color | title `<b>` (13.5px/600) | body `.tc p` (13px `--text-muted`) — verbatim |
|---|---|---|---|
| 1 | `var(--intel)` (violet, arrow icon) | `Goal received` | You asked: `"tighten the board narrative."` (the quote in `.mono`) |
| 2 | `var(--honey)` (check icon) | `Recalled 3 memories` | Strongest was: `⬡ mem #M-204` (`.src` violet) — "Mara wants market work to lead with the regulated-industries angle." + **evidence chip** `.ev`: `confidence 94% · source: chat · Tue · still fresh` |
| 3 | `var(--honey)` (check icon) | `Cross-checked the teardown` | Confirmed 2 of 3 proof points cite customer quotes `⬡ teardown.md` (`.src`) |
| 4 | `var(--healthy)` (green, arrow icon) | `Acted` | Wrote slide 6 around the regulated angle and flagged it for your review. |
`.ev` evidence chip: `font-size:12px; color:var(--text-2); padding:8px 12px; border-radius:
8px; background:var(--surface); border:1px solid var(--line-soft)`. `.src` inline refs =
mono 11px `--intel`. Inline `.mono` quote = mono 11.5px `--text-dim`.
### 6c. Trace action footer (`.traceact`, top border, `background:var(--surface)`,
`padding:16px 22px`, gap:9px) — three buttons `.tbtn2` (12.5px/650, `padding:9px 15px`,
`border-radius:9px`):
1. `.tbtn2.go` (honey fill, `#1a1407` ink) — verbatim `Looks right`
2. `.tbtn2.ghost` (`--surface-2`, `--line-strong` border) — verbatim
`That memory is wrong → correct it`
3. `.tbtn2.danger` (transparent, risk text + risk-tint border `color-mix(in srgb,
var(--risk) 35%,transparent)`) — verbatim `Forget #M-204 & redo`
---
## 7. Trust principle footnote (`.principle`, both views)
Shared component below the content: `display:flex; gap:13px; align-items:flex-start;
padding:18px 20px; border-radius:var(--r-lg); background:var(--bg-2); border:1px solid
var(--line-soft)`. Leading 20px svg `stroke:var(--healthy)` (shield/check on View A;
info-circle on View B). Text 13.5px `--text-muted` `line-height:1.6`, `<b>`=`--text`.
- **View A (verbatim):** **Nothing is remembered behind your back.** Every memory is
inspectable, editable, and forgettable — and forgetting is real: it's removed from recall
and from anything Waggle says next. Confidence and freshness are shown so the agent (and
you) can discount what's old or shaky instead of acting on it blindly.
- **View B (verbatim):** **Every agent action keeps its trace.** The chain from goal →
recalled memories → checks → action is stored with the result, so "why did you do that?"
always has an answer — and the fix (correct or forget the offending memory) is one click
from the explanation.
**Toast** (shared, `#toast`): `.toast` bottom-center pill, `background:var(--surface);
border:1px solid var(--healthy); box-shadow:var(--shadow-lg)`, 8px green dot `.td2`
(`background:var(--healthy)`), slides up on `.show` (2400ms auto-dismiss).
---
## 8. Tokens — mock → PR3 warm tokens (1:1, already aligned)
The mock's `waggle.css` tokens are **identical hex** to PR3's warm tokens in
`apps/web/src/index.css` (same names, same dark + light values). **No remapping needed** —
build directly against the CSS vars below.
| Mock var | Hex (dark / light) | PR3 warm token | Used in screen 19 for |
|---|---|---|---|
| `--honey` | `#e9a52c` / `#b57d12` | `--honey` ✓ | segment-on, accent `<em>`, eyebrow, conf 6084, active filter, go-buttons |
| `--honey-bright` | `#f6c45a` / `#cf932a` | `--honey-bright` ✓ | hex-avatar gradient top |
| `--honey-deep` | `#c07e16` / `#92620a` | `--honey-deep` ✓ | hex-avatar gradient bottom |
| `--honey-wash` | `rgba(233,165,44,.10)` | `--honey-wash` ✓ | active filter bg, stale banner bg |
| `--honey-line` | `rgba(233,165,44,.28)` | `--honey-line` ✓ | active filter border, stale border, hover states |
| `--healthy` | `#6cb78c` / `#3c8a5f` | `--healthy` ✓ | stat #2 green, conf ≥85 ring, fresh●, "Acted" dot, principle icon, toast |
| `--healthy-wash` | `rgba(108,183,140,.12)` | `--healthy-wash` ✓ | corrected banner bg |
| `--attention` | `#e9a52c` / `#b57d12` | `--attention` ✓ | stat #3/#4 honey, conf 6084 ring, stale border, aging● |
| `--risk` | `#db8068` / `#c0573c` | `--risk` ✓ | conf <60 ring, disputed border/strikethrough, danger buttons |
| `--intel` | `#b196dd` / `#7d57b8` | `--intel` ✓ | `` provenance id, trace `.src` refs, "Goal received" dot |
| Dark-ink on honey | `#1a1407` (mock uses `#1a1407`) | warm light `--primary-foreground: #1a1407` ✓ | text on every honey fill |
| `--surface` / `--surface-2` / `--bg-2` | warm graphite / paper | `--surface` / `--surface-2` / `--bg-2` ✓ | cards, rows, action btns, trace bg |
| `--line` / `--line-soft` / `--line-strong` | warm graphite lines | same ✓ | borders |
| `--text` / `--text-2` / `--text-muted` / `--text-dim` | warm text scale | same ✓ | type hierarchy |
| `--r-lg` 18px / `--r-xl` 26px | radii | same ✓ | cards/rows 18px, trace 26px |
| `--mono` JetBrains Mono / `--sans` Hanken Grotesk | fonts | same ✓ | provenance/conf mono; body sans |
**Reuse existing warm primitives** (`apps/web/src/components/os/warm/`):
- `ProvenanceLine.tsx` — already renders `⬡ source · when` in mono `--intel` (the §5b
provenance pattern). Extend with a freshness `` segment + `source:` prefix to match.
- `HexAvatar.tsx` — the §6a honey-gradient hex with dark-ink icon.
- `SectionLabel.tsx` — eyebrow/mono-uppercase labels.
- `DotLive.tsx` — the freshness `` dot.
- `ModelPill` / `RunChip` / chip patterns — the filter-chip + go-button shapes.
---
## Component inventory (6-line summary)
1. **Header/segmented control** — `Memory Trust · view` eyebrow + 2-tab switch (`Manage memory` default-on honey-fill | `Why did you do that?`) + swapping `.vlabel` + theme toggle, toggling between two full views.
2. **Editorial hero (per view)** — honey eyebrow, 28px H1 with honey `<em>` accent ("correct, age, and forget." / "why did you do that?"), 64ch body with bold spans.
3. **Stat bar (View A)** — 4 cards: 142 white / 128 green / 9 honey-warn / 3 honey-warn, repeat(4,1fr)→2-col @820px.
4. **Toolbar (View A)** — search input ("Search what Waggle knows… or ask it to forget something") + 4 filter chips (All on / Stale / Needs confirm / Forgotten).
5. **Memory row** — 3-col [conf ring (color by ≥85 green / ≥60 honey / <60 terracotta) + CONF] · [bold fact + `⬡ id · source · ● freshness` provenance + optional corrected/stale banner] · [edit + danger-forget icon buttons]; 5 seed rows incl. 1 stale + 1 disputed/strikethrough.
6. **Why-trace (View B)** — hex-avatar header (`trace #a1f9`) + 4-node connector chain (Goal→Recalled→Cross-checked→Acted, dots intel/honey/honey/healthy, evidence chip) + 3-button footer (Looks right / correct it / Forget #M-204 & redo); shared trust-principle footnote + green toast on both views.

View File

@@ -0,0 +1,249 @@
# PR3.5 Memory-Trust — Real Memory-Store API Surface
> Recon for the warm-Hive PR3.5 Memory-Trust arc. Question: what can the
> Memory-Trust UI wire to **as it exists today**, vs what must be added —
> and what it must **never fabricate**.
>
> Repo: `D:/Projects/waggle-os`. Substrate: `packages/hive-mind-core/src/mind/`.
> Server surface: `packages/server/src/local/routes/`.
> All claims below are quoted file:line. **Brutally honest split: PR3.5 must not invent data the store doesn't hold.**
---
## TL;DR Capability Matrix
| Feature | Verdict | Where the truth lives |
|---|---|---|
| **source** (provenance class) | ✅ **REAL** (stored column) | `memory_frames.source` |
| **forget** (delete) | ✅ **REAL** (store + 2 routes) | `FrameStore.delete()` + `DELETE /api/memory/:id` |
| **correct** (edit content) | ✅ **REAL** (store + 2 routes) | `FrameStore.update()` + `PATCH /api/memory/:id` |
| **freshness / staleness** | 🟡 **DERIVABLE** (compute fn exists; not surfaced) | `computeTemporalScore()` over `created_at` / `last_accessed` |
| **confidence** | 🟡 **DERIVABLE / PARTIAL** (metadata blob; only set at harvest) | `memory_frames.metadata.confidence` (B2 heuristic) |
| **trace** ("why did you do that?") | 🟡 **DERIVABLE** (rich store + 1 route; not per-frame) | `execution_traces` + `GET /api/agents/:id/traces` |
| **confirm / verified status** | 🔴 **MUST-BUILD** (no per-frame confirm concept) | absent — see §4 |
**One-line split:** `source`, `forget`, `correct` are **REAL and route-exposed today**. `freshness`, `confidence`, and `trace` are **DERIVABLE** (the signals exist in the substrate but are not projected onto the Chat/Workspace surfaces, and confidence is only populated on the harvest path). A per-memory **"confirm / needs-confirm / verified"** status is **MUST-BUILD** — it does not exist.
---
## 1. The FRAME shape
Canonical row type — `packages/hive-mind-core/src/mind/frames.ts:27-48`:
```ts
export interface MemoryFrame {
id: number;
frame_type: FrameType; // 'I' | 'P' | 'B'
gop_id: string;
t: number;
base_frame_id: number | null;
content: string;
importance: Importance; // critical|important|normal|temporary|deprecated
source: FrameSource; // see below
access_count: number;
created_at: string;
last_accessed: string;
content_hash?: string | null; // dedup
metadata?: string; // JSON blob (Phase 2B) — provenance/classification
}
```
DDL backing it — `schema.ts:47-73` (`memory_frames`). Every field above is a real
stored column; `metadata` is `TEXT NOT NULL DEFAULT '{}'` (`schema.ts:65`).
Per-field verdict against the requested set:
- **id** — REAL (`frames.ts:28`, PK `schema.ts:48`).
- **content / text** — REAL (`frames.ts:33`, `schema.ts:53`). The field is `content`, NOT `text`.
- **source** — ✅ **REAL stored column.** `frames.ts:25` defines `FrameSource = 'user_stated' | 'tool_verified' | 'agent_inferred' | 'import' | 'system' | 'personal' | 'workspace' | 'team_sync'`. The DB CHECK is narrower — `schema.ts:56-57` only allows `('user_stated','tool_verified','agent_inferred','import','system')` (the `personal/workspace/team_sync` members are search-time mind labels, not persisted provenance — see `memory.ts:41-43`).
- **confidence / score** — 🟡 **NOT a frame column.** No `confidence` or `score` column on `memory_frames`. Two distinct things wear the name:
- `score` is a **search-time, computed** ranking field added by HybridSearch (returned in `normalizeFrame` at `memory.ts:58`), never stored.
- `confidence` (0-100) is a **derived metadata field** that rides `memory_frames.metadata` JSON, projected by `normalizeToMemory` at `memory-center.ts:100` (`typeof meta.confidence === 'number' ? meta.confidence : undefined`). It is only **populated on the harvest path** (`harvestConfidence`, see §"confidence" below). Curated/agent/quick-capture writes leave it `undefined`.
- (`confidence REAL` does exist — but on `knowledge_relations`, `schema.ts:109`, not on frames.)
- **created_at** — REAL (`frames.ts:37`, `schema.ts:59`, default `datetime('now')`).
- **updated_at** — 🔴 **NOT on frames.** The frame table has no `updated_at` column. `identity`/`procedures` tables have one (`schema.ts:20,166`); frames do not. An `updatedAt` is faked into the `metadata` blob on PATCH (`memory-center.ts:336`). The shared `Memory.updatedAt` reads it from metadata (`memory-center.ts:112`).
- **accessed_at / last_used** — REAL: `last_accessed` (`frames.ts:38`, `schema.ts:60`), bumped by `touch()` (`frames.ts:184-191`). `access_count` (`frames.ts:36`) is the use counter.
- **decay / staleness** — 🟡 **NOT stored; DERIVABLE.** No decay column. Decay is a pure compute over timestamps in `scoring.ts` (see §5).
- **type / kind** — split brain:
- `frame_type` (`'I' | 'P' | 'B'`) is the substrate-internal kind (incremental/patch/branch), REAL (`frames.ts:29`).
- `importance` (`critical|important|normal|temporary|deprecated`) is the closest stored "salience" axis, REAL (`frames.ts:35`).
- The product-facing `MemoryKind` (`fact|decision|task|preference|strategy|learning|goal|entity`, `shared/types.ts:354-356`) is **NOT a column** — it rides `metadata.kind`, projected at `memory-center.ts:91`, defaulting to `'fact'` when absent.
**Verdict — {confidence, freshness/recency, source}:**
- **source** → REAL stored field.
- **freshness/recency** → DERIVED (no column; computed from `created_at`/`last_accessed`).
- **confidence** → metadata-blob field, REAL-but-sparse (only harvest sets it); treat as DERIVABLE/PARTIAL for any non-harvested frame.
---
## 2. FORGET (delete) — ✅ REAL
Store: `FrameStore.delete(id): boolean``frames.ts:321-334`. Hard delete; cleans up
FTS, vec, KG entity links, and nullifies self-referential FKs. There is **no
tombstone** — it's a real row removal. Also `deleteByContentPrefix` (`frames.ts:343-354`).
Routes that expose it:
- `DELETE /api/memory/frames/:id``memory.ts:571-607` (legacy frame-id contract; workspace-first then personal fallback).
- `DELETE /api/memory/:id``memory-center.ts:390-411` (bare-id contract, hard delete, `mind`-strict).
Both emit an audit event (`eventType: 'memory_delete'`, `memory.ts:601`, `memory-center.ts:402`).
**Suggested approach:** wire the Memory-Trust "Forget" action straight to `DELETE /api/memory/:id?mind=…`. No new backend.
---
## 3. CORRECT (edit content) — ✅ REAL
Store: `FrameStore.update(id, content, importance?)``frames.ts:281-301`. Updates the
row, FTS index, vec index, and maintains `content_hash`. Returns the updated frame.
Routes:
- `PUT /api/memory/frames/:id``memory.ts:466-533` (content + importance; XSS-sanitized `memory.ts:483`).
- `PATCH /api/memory/:id``memory-center.ts:296-352` (content/importance **and** metadata classification: kind/scope/tags/status/title/evidence; stamps `metadata.updatedAt`).
**Suggested approach:** "Correct this memory" → `PATCH /api/memory/:id`. It already supports
editing content and reclassifying. No new backend.
---
## 4. CONFIRM / "needs confirm" / verified status — 🔴 MUST-BUILD (mostly)
**There is NO per-frame "confirmed / needs-confirmation / verified" concept.** Honest accounting of the near-misses:
- `FrameSource` has a `tool_verified` member (`frames.ts:25`, `schema.ts:57`) — but that's a **provenance class set at write time** (this fact came from a verified tool call), not a user-confirmation lifecycle. It's never toggled after creation.
- `execution_traces.outcome` has a `'verified'` value (`schema.ts:223`, `execution-traces.ts:20`) — but that's about an **agent run** passing a verifier gate, not a memory being confirmed.
- The shared `MemoryStatus` union (`shared/types.ts:514-515`) has `'unreviewed' | 'low_confidence' | 'conflict'` — the **vocabulary for a review lifecycle exists** and is already projected (`memory-center.ts:84-85`) and filterable (`memory-center.ts:198`). But: nothing **writes** `unreviewed` today (the create path stamps `'active'`, `memory-center.ts:273`; harvest commit is the only intended `unreviewed` producer per `types.ts:506-507` but that write was not confirmed in this recon), and `low_confidence`/`conflict` are explicitly noted as *"may be derived at recall-time rather than persisted"* (`types.ts:510-511`) — i.e. not implemented.
**Verdict:** A "Confirm" / "Needs your confirmation" affordance is **MUST-BUILD**, but cheaply:
the `metadata.status` field + the `MemoryStatus` union are the rails. Add a
`POST /api/memory/:id/confirm` that sets `metadata.status='active'` (clearing `unreviewed`),
mirroring the existing `/archive` route (`memory-center.ts:354-386`). The "needs confirm"
queue = `GET /api/memory?status=unreviewed` (already works — `memory-center.ts:198`).
**Do NOT show a "verified ✓" badge unless the frame is genuinely `source==='tool_verified'` or `status` was explicitly set** — anything else is fabrication.
---
## 5. STALE / freshness / decay — 🟡 DERIVABLE (not surfaced)
Real signal exists as **pure compute**, not a stored flag — `scoring.ts`:
- `computeTemporalScore(iso)``scoring.ts:52-63`. Returns `1.0` if within **7 days**
(`RECENCY_BOOST_DAYS`, `scoring.ts:38`), else exponential decay with a **30-day half-life**
(`HALF_LIFE_DAYS`, `scoring.ts:37`): `Math.pow(0.5, daysSince / 30)`.
- Decay anchors on **`created_at`** (write time), NOT `last_accessed` — deliberate
(`scoring.ts:91-97`): `last_accessed` is bumped by `touch()` on every read, so decaying on
it made the dimension constant noise. Use `created_at` for "age".
- The substrate also auto-prunes by age in `FrameStore.compact()` — temporary frames > 30d,
deprecated > 90d (`frames.ts:366-389`) — a real staleness policy, but a background sweep, not a per-frame badge.
**Verdict:** "Stale · worth a review" is **DERIVABLE today** with zero new storage:
compute `daysSince(created_at)` (or call `computeTemporalScore`) FE-side or in a thin route.
A reasonable "stale" threshold: temporal score below ~0.5 ≈ older than one half-life (~30d),
or simply `created_at` older than N days for `importance ∈ {normal, temporary}`.
**Constraint:** `created_at` IS projected on the legacy `/api/memory/frames` and `/search`
responses (`memory.ts:56`), so the FE already has the input. **Do NOT invent a "freshness %"
that implies stored decay** — present it as "last touched / age", computed honestly.
---
## 6. "WHY DID YOU DO THAT?" trace — 🟡 DERIVABLE (rich; not per-frame)
A real, rich execution-trace store exists — `execution-traces.ts` + `execution_traces`
DDL (`schema.ts:215-233`). Per "unit of agent work" it records:
- `outcome` (`success|corrected|abandoned|verified|pending`, `execution-traces.ts:20`),
`cost_usd`, `duration_ms`, `created_at`/`finalized_at`, `session_id`/`persona_id`/
`workspace_id`/`model` (`execution-traces.ts:75-88`).
- A structured `trace_json` payload (`TracePayload`, `execution-traces.ts:48-72`):
**`input`, `output`, `reasoning[]` (step text + ts), `toolCalls[]` (tool, args, result, ok,
durationMs, ts — `execution-traces.ts:22-36`), `artifacts[]`, `tokens`, optional `harness`
gate results, and `correctionFeedback`.** This is exactly the data a "why did you do that?"
panel needs.
Production wiring (it IS populated for chat):
- The chat loop creates a `TraceRecorder` and `start()`s a trace per turn
(`chat.ts:1277-1286`), records reasoning + tool calls through it, and `finalize()`s with
outcome+output+tokens (`chat.ts:1413-1424`); aborted/errored turns finalize as
`'abandoned'` (`chat.ts:1648-1649`, `1713`).
- Corrections downgrade a prior trace via `markCorrected()` (`execution-traces.ts:297-310`,
feedback noted at `chat.ts:1651`).
Route exposure:
- `GET /api/agents/:id/traces``agents.ts:469-505`. Returns id/ts/session/workspace/model/
**outcome/cost/durationMs/tools[]** per trace. **It filters by the `agent:{id}` tag**
(`agents.ts:486-487`).
**Two honest gaps for PR3.5:**
1. **The chat trace `start()` does NOT pass a `tags:['agent:…']`** (`chat.ts:1279-1285`),
so the agent-traces route's tag filter will **not** surface conversational traces. The
richest traces (chat reasoning + tool calls) are written but not addressable by that route.
2. **No frame↔trace backlink.** Nothing links a saved `memory_frame` to the
`execution_trace` that produced it (no `trace_id` column, no metadata field; grep of
chat/home/workspace routes for any `traceId`/`trace_id` link returned nothing). So
"why is THIS specific memory here?" can't be answered from the trace store today —
you can only show "what the agent did in this turn/session", not "the decision that wrote this frame".
**Verdict:** Trace data is **DERIVABLE and rich** for the session/turn granularity, but a
**per-frame "why" requires MUST-BUILD plumbing** (a `trace_id` on the frame metadata at
write time, plus a `GET /api/memory/:id/trace` resolver). For PR3.5, the **cheap honest win**
is a session/turn-scoped trace view (reasoning steps + tool calls) — and either (a) add the
`agent:` tag to chat `start()`, or (b) add a thin `GET /api/sessions/:id/traces` reading
`traceStore.queryParsed({ sessionId })` (the store already supports it, `execution-traces.ts:328`).
**Do NOT synthesize a "reason" string for a frame that has no linked trace.**
---
## 7. SERVER ROUTES exposing memory to apps/web
Two plugins. **Legacy frame ops** (`memory.ts`) + **shared-`Memory`-entity contract**
(`memory-center.ts`). Quoted with method + path + file:line:
### `packages/server/src/local/routes/memory.ts`
| Method | Path | Line | Notes |
|---|---|---|---|
| GET | `/api/memory/search?q&scope&limit&workspace&since&until` | `memory.ts:126` | HybridSearch; returns normalized frames incl. computed `score`, `source` |
| GET | `/api/memory/frames?workspace&limit&since&until` | `memory.ts:194` | recent frames, no query needed (Memory tab initial load) |
| POST | `/api/memory/frames` | `memory.ts:245` | direct write; optional entity extraction; XSS-sanitized; dedup |
| GET | `/api/memory/stats?workspace&scope` | `memory.ts:397` | counts only (mind-isolation: `?scope=all-minds` opt-in, `memory.ts:425-438`) |
| PUT | `/api/memory/frames/:id` | `memory.ts:467` | **CORRECT** — edit content/importance |
| PATCH | `/api/memory/frames/:id/access` | `memory.ts:539` | atomic `access_count++` (touch) |
| DELETE | `/api/memory/frames/:id` | `memory.ts:571` | **FORGET** — hard delete |
| POST | `/api/quick-capture` | `memory.ts:614` | Home quick-capture → frame (+ awareness task row) |
### `packages/server/src/local/routes/memory-center.ts` (shared `Memory` shape)
| Method | Path | Line | Notes |
|---|---|---|---|
| GET | `/api/memory?mind&kind&status&scope&q&minConfidence&limit` | `memory-center.ts:167` | list as `Memory`; **status/confidence filters live here** |
| GET | `/api/memory/:id` | `memory-center.ts:215` | one memory, normalized |
| POST | `/api/memory` | `memory-center.ts:235` | curated create (sets `metadata.kind/scope/status='active'/confidence`) |
| PATCH | `/api/memory/:id` | `memory-center.ts:296` | **CORRECT** — content + reclassify; stamps `updatedAt` |
| POST | `/api/memory/:id/archive` | `memory-center.ts:355` | reversible Archive (`status='archived'`) — template for a `/confirm` route |
| DELETE | `/api/memory/:id` | `memory-center.ts:390` | **FORGET** — hard delete, mind-strict |
| POST | `/api/memory/merge` | `memory-center.ts:416` | merge ≥2 → concat + archive originals (C11) |
**Projection note (load-bearing for the warm-Hive provenance pill):** the legacy
`memory.ts` `normalizeFrame` DOES carry `source` provenance (`memory.ts:48-49`), and
`memory-center.ts` `normalizeToMemory` carries `source` + `sourceUrl`/`sourceId`/`confidence`
(`memory-center.ts:97-100`). **BUT** the Chat and Workspace *context* surfaces do not read
those routes — `workspace-context.ts:283-284` selects only `content, importance, created_at`
from `memory_frames`, omitting `source`. **This is the "`frame.source` 1-field server
projection" gap flagged in the S2 handoff** — adding `source` (and `created_at`, already there)
to that projection is the MUST-BUILD that unlocks the ⬡ provenance pill on Chat + Workspace.
---
## PR3.5 build guidance (do-not-fabricate checklist)
| UI affordance | Wire to | New work |
|---|---|---|
| ⬡ provenance pill (source) | `Memory.source` from `/api/memory` ✅; for Chat/Workspace context add `source` to `workspace-context.ts:283` SELECT | 1-field projection (MUST-BUILD, tiny) |
| Forget button | `DELETE /api/memory/:id` ✅ | none |
| Correct / edit | `PATCH /api/memory/:id` ✅ | none |
| "Stale · review?" | compute from `created_at` (already projected) via `computeTemporalScore` | FE compute / thin helper (DERIVABLE) |
| Confidence chip | `Memory.confidence` (only present on harvested frames) | show **only when present**; never default a number (PARTIAL) |
| Confirm / needs-confirm | `POST /api/memory/:id/confirm` (set `metadata.status`) + `GET /api/memory?status=unreviewed` | new route mirroring `/archive` (MUST-BUILD, cheap) |
| "Why did you do that?" | `GET /api/agents/:id/traces` (session-level) | per-frame "why" needs a `trace_id` backlink (MUST-BUILD); session/turn view is DERIVABLE |
**Hard rule:** confidence, freshness, and trace-reason are the three places PR3.5 could
silently fabricate. Confidence is sparse (harvest-only) → hide when absent. Freshness has no
stored decay → present as honest age, not a stored %. Per-frame "why" has no backlink → only
show a reason when a real linked trace exists.

View File

@@ -0,0 +1,249 @@
# PR4 Recon · Slice 1 — Current Marketplace Front-End
> Read-only recon for PR4 (warm-Hive). Maps the **current** Marketplace FE against the
> screen-09 contract (Variation A browse+ask, Variation B inline-in-chat, shared install
> "sync" store). Cites real code; flags the delta; names exact integration points.
> Branch: `feature/warm-hive-pr4`. No source was modified.
---
## 1. What exists today
The Marketplace surface today is the **UX-Refactor Phase-4B "consolidated Extend
surface"** — a faceted *package/extension browser*, NOT the agent-searchable shelf the
design contract describes. There are two real surfaces:
- **A grid browser** — `MarketplaceApp.tsx` (route `/marketplace``MarketplaceRoute.tsx``SurfaceBoundary`).
- **An inline-in-chat install card** — `CapabilityRequestCard.tsx`, parsed out of agent
text by `capability-request-parser.ts` (`segmentText`) inside `TextBlock`.
These two surfaces are **independent** — they do **not** share an install store, do not
share micro-state vocabulary, and do not cross-reflect (installing in one does not update
the other). That is the single biggest structural gap vs the screen-09 "sync" mandate.
### 1a. The grid browser — `MarketplaceApp.tsx`
Layout (top to bottom), all in one file, 418 LOC:
- **Header** (`apps/web/src/components/os/apps/MarketplaceApp.tsx:273-331`): a `Store` icon
(honey via `var(--honey-500)`), title "Marketplace", and a right-aligned
`"{visible.length} extensions"` count (`:277`). This count is a *render-time array
length*, NOT a per-workspace "N in this workspace" chip bar.
- **Two tabs** — `Browse | Audit` (`:283-297`), `role="tablist"`. Audit renders
`InstallAuditPanel` (the C18 shared install-audit feed). Browse is the grid.
- **B7 facet rail** (`:302-317`): pills for `['all', ...EXTENSION_TYPES]` =
**All · Skills · Agents · Connectors · MCPs · Models · Templates** (7 facets, labels in
`FACET_LABELS` `:37-45`). `EXTENSION_TYPES` is canonical in
`packages/shared/src/types.ts:375-378` (`skill·agent·connector·mcp·model·template`).
- **A keyword search input** (`:319-328`) — a plain `<Input>` with a `Search` icon,
placeholder "Search skills, agents, connectors, MCPs, models, templates…". Debounced
300ms (`:202-209`); marketplace facets re-query the server, others client-filter.
- **Body** (`:334-385`): federated-provenance note (`:339-343`), loading / error+Retry /
empty states (honest, not fake-empty), then a vertical list of `<ExtensionCard>`.
Per-card UI — `ExtensionCard.tsx`:
- A generic `Package` icon (NOT a per-kind badge), name, an Installed/Available
`StatusBadge`, optional scan-status badge.
- A row of small text chips: **`ext.type`** (this is the only "kind badge" today — a plain
text pill `:44`), optional category, optional trust, and a muted `ext.source` label.
- A single right-side action that is **lifecycle/kind-aware but NOT type-aware**:
- installable + not installed → **"Install"** (Download icon) — same label for every type.
- installable + installed (kind `package`) → **"Remove"** (Trash icon, destructive).
- federated (connector/agent/mcp-catalog/model/template) → **"Open in <app>"** deep-link
(`ExternalLink`), e.g. "Connector Hub", "Agent Center", "MCP Hub" (`:72-79`).
- There is **no install count** rendered, no progress→done micro-states beyond a single
spinner on the Install button (`installing``<Loader2>` `:68`).
### 1b. Data flow — federate-at-read (A5)
The pure merge/normalize layer is `apps/web/src/lib/extension-catalog.ts` (no adapter
import — unit-testable). `loadFacet` (`MarketplaceApp.tsx:116-188`) fans out to per-domain
adapter calls based on the active facet, `Promise.allSettled`-merges, then
`sortExtensions`. The mapping (from the file header `extension-catalog.ts:7-14`):
| facet | adapter call | normalizer | install path | installable |
|-----------|------------------------------------------------|-----------------------|---------------------|-------------|
| skill | `getMarketplace({type:'skill'})` + `getMarketplacePacks()` | `fromMarketplacePackage` / `fromSkillPack` | real (POST /api/marketplace/install) | pkg ✓ / pack ✗ |
| mcp | `getMcps()` + `getMarketplace({type:'mcp'})` | `fromMcpCatalogRow` / `fromMarketplacePackage` | catalog → MCP Hub; registry-pkg → real | catalog ✗ / pkg ✓ |
| agent | `getPersonas()` | `fromPersona` | local (Agent Center) | ✗ |
| connector | `getConnectors()` | `fromConnector` | local (Connector Hub)| ✗ |
| model | `getModels()` | `fromModel` | local (Settings) | ✗ |
| template | `getWorkspaceTemplates()` | `fromTemplate` | local (Home) | ✗ |
Install actions (`MarketplaceApp.tsx`):
- `handleInstall` (`:224-251`) — only `kind==='package'` installs here; goes through the
shared `ApprovalModal` first (`buildInstallRequest` `:76-92`, scan-derived risk via
`installRiskFor`/`classifyInstallRisk`). 403 `TIER_INSUFFICIENT` → dispatches
`waggle:tier-insufficient` (UpgradeModal); 403 SecurityGate block → destructive toast.
On success it **optimistically flips local state** (`:231`) — this is the *only* "sync",
and it is purely local to this component's `extensions` array.
- `handleUninstall` (`:253-262`) — confirmed via a second `ApprovalModal`
(`buildRemoveRequest` `:65-74`).
- `handleOpenIn` (`:264-266`) — dispatches `waggle:open-app` for federated deep-links.
Adapter methods consumed (all in `apps/web/src/lib/adapter.ts`):
`getMarketplace` (`:2082-2092`, `/api/marketplace?type=…`), `getMarketplacePacks`
(`:1309-1313`), `installMarketplacePackage` (`:1338-1343`, `fetchRaw` so status survives),
`uninstallMarketplacePackage` (`:1345-1350`), `searchMarketplace` (`:1334-1336`, used by
the inline card), `getMcps` (`:2004-2011`), `getConnectors` (`:1958-1964`),
`connectConnector` (`:1971-1979`, `/api/connectors/:id/connect`), `installMcp`
(`:2017-2029`, `/api/mcps/install`), `getPersonas`, `getModels`, `getWorkspaceTemplates`,
`getExtendAudit` (`:2095-2103`, C18 audit feed).
### 1c. The inline-in-chat card — `CapabilityRequestCard.tsx`
Parsed from agent text by `capability-request-parser.ts:segmentText` — two patterns: a
structured HTML marker `<!--waggle:capability_request {...}-->` (Pattern A) and a legacy
`` `install_capability` with name "X" and source "Y" `` phrasing (Pattern B). Card shows
name, a source pill, optional `reason` ("why"), and Install/Dismiss. Phases:
`pending → installing → installed | declined | failed` (`:17`). For marketplace kind it
**resolves name → packageId via `searchMarketplace`** then `installMarketplacePackage`;
for skill kind it calls `adapter.installPack`. 403 → `waggle:tier-insufficient`.
This card is the seed of screen-09 Variation B but it is **skill/marketplace-only** (no
connector or MCP kind), has **no vault-aware approval** ("token goes to your vault"), no
"connected follow-up", and shares **no state** with the grid.
### 1d. Test coverage — `phase4b-marketplace-extend.test.tsx`
11 tests, all green, pinning the *current* Phase-4B contract: federate-at-read of all six
domains, honest federated notes, browse-only packs (no pack install testid), registry-MCP
packages install through the real route, ApprovalModal-gated install with scan-derived
risk, 403-tier-vs-403-securitygate split, all-backends-down error+Retry, Remove confirm,
failed-uninstall does-not-flip-installed, and the Audit tab type filter. **Any PR4 rework
of MarketplaceApp must update / supersede these** — they assert the current testids
(`extension-install-pkg:7`, `extension-facets`, `federated-note`) and the
`{ type, limit:30 }` call shape.
---
## 2. Gap vs screen-09 Variation A (the delta)
| screen-09 element | current state | delta |
|---|---|---|
| **Centered agent-search bar** ("Describe what you want to do…" → "Ask the agent") | plain keyword `<Input>` that filters/queries the registry; no agent round-trip | MISSING — no NL→agent-suggestion call; search is literal substring/registry-`?query=`. |
| **Example chips** | none | MISSING. |
| **Agent-suggestion box** (recommends a connector + skill + tool, each with a "why" + install button) | none in the grid (only the unrelated inline `CapabilityRequestCard` carries a `reason`) | MISSING — no suggestion surface, no "why" reason rendered in the grid, no per-suggestion install. |
| **"N in this workspace" count bar (chips)** | a single render-time `"{visible.length} extensions"` text label `:277` | PARTIAL/WRONG semantics — it counts *visible filtered rows*, not *installed-in-this-workspace* capabilities, and is not a chip bar. |
| **Category filter All/Skills/Connectors/MCP** | 7-facet rail All/Skills/Agents/Connectors/MCPs/Models/Templates | SUPERSET — current has the 3 design facets *plus* Agents/Models/Templates. Design shows 4 (All/Skills/Connectors/MCP). PR4 must decide: collapse to the design's 3-shelf framing or keep the 6-domain superset. |
| **Card grid** (kind badge + name + desc + **install count** + Add button) | vertical *list* of cards; "kind badge" is a plain text `ext.type` pill; **no install count**; single "Install"/"Remove"/"Open in" action | PARTIAL — needs a real grid, a styled kind badge, an install-count field, and **type-aware** primary actions. |
| **Type-aware one-click flows** (skill Add→Adding…→Added; connector Connect→Signing in…→Connected ~1.1s token→vault; MCP Enable→Enabling…→Enabled) | one generic "Install" label + single spinner; connectors/MCPs are *federated deep-links* (Open-in), not in-place actions | MISSING — no per-type verb, no progress→done micro-states, no in-place connect/enable; connector/MCP installs currently *leave* the Marketplace. |
| **Toast + count-bar update per action** | install toasts fire; count bar does not track installed-count | PARTIAL — toasts ✓, count-bar-update ✗. |
| **Shared "sync" store** (one store powers grid + agent-pick + inline card; install anywhere reflects everywhere) | grid keeps a local `extensions` array; inline card keeps its own `phase`; no shared store, no cross-reflect | MISSING — the headline architectural gap. |
### Variation B (inline) delta
- `CapabilityRequestCard` exists but is **skill/marketplace-only** — no connector / MCP
kinds. Needs the same type-aware verbs as the grid (Connect/Enable).
- **No vault-aware approval** copy ("token goes to your vault"). The warm
`InlineApprovalCard` (`components/os/warm/InlineApprovalCard.tsx`) is the obvious
adoption target — it already renders `--honey-wash` + risk vocabulary + Approve/Not-now,
and connectors carry credentials via `connectConnector(id, {token,…})`.
- **No "connected follow-up"** state distinct from generic "Installed".
### Data availability for "install count"
The marketplace DB schema **does** persist a `downloads` (and `stars`) column
(`packages/server/src/local/routes/marketplace.ts:860`, `upsertPackage`), so an install
count is *backed by data*. But it is **not surfaced**: the FE `MarketplacePackageRow`
(`extension-catalog.ts:54-65`) omits `downloads`, `getMarketplace` returns
`packages: unknown[]`, and `Extension` has no `installs` field. PR4 would thread
`downloads` → `Extension.installCount` → `ExtensionCard`.
---
## 3. Warm-Hive tokens / components to adopt
MarketplaceApp/ExtensionCard are still on the **legacy semantic-token** vocabulary
(`bg-secondary/20`, `text-muted-foreground`, `border-border/30`, `var(--honey-500)`,
`bg-primary`). The warm-Hive PR1+ vocabulary they should migrate to:
- **Tokens** (defined `apps/web/src/waggle-theme.css`, used across `warm/*`):
`--honey`, `--honey-wash`, `--honey-line`, `--shadow-honey`, `--surface`, `--line`,
`--line-strong`, `--text`, `--text-2`, `--text-dim`, `--attention`.
- **`AskBar`** (`components/os/warm/AskBar.tsx`) — the warm full-width ask pill (honey "+",
⌘K hint, honey send). This is the direct fit for the screen-09 "centered agent-search
bar" (swap placeholder to "Describe what you want to do…", wire submit → agent
suggestion instead of free chat).
- **`InlineApprovalCard`** (`components/os/warm/InlineApprovalCard.tsx`) — the vault-aware
honey approval card for Variation B's connector token→vault approval.
- **`SectionLabel`**, **`IconTile`/`HexCheckTile`**, **`StatusBadge`**
(`components/ui/status-badge.tsx`, already used) for kind badges / count chips.
- **`StreakChip`/`RunChip`** patterns (`components/os/warm/`) as the visual idiom for the
"N in this workspace" chip bar.
- All warm atoms are barrel-exported from `apps/web/src/components/os/warm/index.ts`.
---
## 4. Exact integration points a PR4 build would touch
- **Grid host**: `apps/web/src/components/os/apps/MarketplaceApp.tsx` — `MarketplaceApp`
component; `loadFacet` (fan-out/merge), `handleInstall`/`handleUninstall`/`handleOpenIn`,
`buildInstallRequest`/`buildRemoveRequest`/`installRiskFor`.
- **Card**: `apps/web/src/components/os/apps/extend/ExtensionCard.tsx` — `ExtensionCard`
(add kind badge styling, install count, type-aware action verbs + micro-states).
- **View-model + normalizers**: `apps/web/src/lib/extension-catalog.ts` — `Extension`
interface (add `installCount`, an `installState` micro-phase, make connector/mcp
`installable`), `fromConnector`/`fromMcpCatalogRow`/`fromMarketplacePackage`,
`filterExtensions`, `sortExtensions`.
- **Inline card**: `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx`
+ `capability-request-parser.ts` (`segmentText`, `CapabilityRequest`) — extend `kind`
to `'connector' | 'mcp'`, wire `connectConnector`/`installMcp`, vault-aware approval.
- **Shared store (NEW)**: nothing exists today. A PR4 "sync" store would be a new module
(e.g. a context/zustand/event-bus in `apps/web/src/lib/` or `providers/`) that both
`MarketplaceApp` and `CapabilityRequestCard` subscribe to; cross-reflect can also reuse
the existing `window.dispatchEvent('waggle:*')` event idiom already used for
`waggle:open-app` and `waggle:tier-insufficient`.
- **Adapter (existing, reuse)**: `apps/web/src/lib/adapter.ts` — `getMarketplace`,
`installMarketplacePackage`, `uninstallMarketplacePackage`, `searchMarketplace`,
`getConnectors`, `connectConnector`, `getMcps`, `installMcp`, `getExtendAudit`.
- **Types**: `packages/shared/src/types.ts` — `EXTENSION_TYPES` / `ExtensionType`
(`:375-378`) is the canonical facet list; decide whether the design's 4-facet shelf
collapses or wraps this 6-domain tuple.
- **Server (read-only context)**: `packages/server/src/local/routes/marketplace.ts`
(`/api/marketplace`, `/install`, `/uninstall`, `downloads` column),
`packages/server/src/local/routes/extend.ts` (C18 audit).
- **Route wrapper**: `apps/web/src/routes/MarketplaceRoute.tsx` (unchanged unless the shell
framing changes).
- **Tests to supersede**: `apps/web/src/test/phase4b-marketplace-extend.test.tsx`.
---
## 5. Risks
- **Test churn**: 11 existing tests pin current testids and the `{type,limit:30}` call
shape; a Variation-A rework rewrites most of them.
- **Facet framing decision**: design says 3 shelves (Skills/Connectors/MCP); code has 6
domains incl. Agents/Models/Templates with honest federated notes. Collapsing loses the
honesty work; keeping diverges from the design's "one simple shelf".
- **In-place connect/enable vs deep-link**: design wants connectors/MCPs installed *in the
Marketplace* (Connect→Connected, Enable→Enabled). Today they are federated deep-links to
Connector Hub / MCP Hub which own the real OAuth/security-scan/approval flows
(`connectConnector`, `installMcp` w/ SecurityGate). Doing it in-place must NOT bypass
those flows — risk of duplicating or weakening security gating.
- **"Sync" consistency**: a shared store must reconcile optimistic flips with server truth
(install can 403/securitygate-block after the optimistic flip) across both surfaces.
- **Install count truthfulness**: `downloads` is local-single-user and often 0; rendering
it as a social "install count" may be misleading (mind-isolation / honest-stats ethos
the repo enforces elsewhere).
---
## 6. Open questions for the lead
1. **Facets**: collapse to the design's All/Skills/Connectors/MCP (3 shelves), or keep the
6-domain superset (Agents/Models/Templates) with federated notes?
2. **In-place vs deep-link** for connector/MCP: do Connect/Enable happen *inside*
Marketplace (new in-place flow that must reuse the Hub security/OAuth paths), or stay
deep-links? The design clearly wants in-place.
3. **Shared "sync" store** mechanism: new context/store module vs reuse the existing
`window` CustomEvent bus? What's the source of truth (server re-read vs optimistic +
reconcile)?
4. **"N in this workspace"**: counts *installed* capabilities (which backend gives the
per-workspace installed set?) — is `getExtendAudit` / a capabilities-status read the
source, or a new `/api/marketplace?installed=true&workspace=…`?
5. **Install count**: surface `downloads` honestly, or omit it given local-single-user
data is ~0 and the repo's honest-stats ethos?
6. **Agent-suggestion box**: which backend produces the connector+skill+tool
recommendation with a "why"? Is there an agent/LLM route to call, or is this a curated
heuristic over the registry?
7. **Inline Variation B**: extend `CapabilityRequestCard` in place, or replace it with a
shared picker component used by both grid and chat?

View File

@@ -0,0 +1,183 @@
# PR4 Recon — Slice 2: Marketplace Backend + Routes
**Design contract:** SCREENS.md §09 (Marketplace — skills + connectors + MCP as one shelf, agent-searchable).
**PR4 scope (BUILD-PLAN.md §6, line 142):** "Marketplace + **shared install store ('sync')** (grid + agent-pick + inline-in-chat)" → `MarketplaceApp`, **new install store**, screen 09.
**Branch:** `feature/warm-hive-pr4`. **Mode:** READ ONLY (no source touched).
This doc maps the *existing* backend territory a PR4 "shared install store (sync)" would build ON. It does not propose the plan.
---
## 0. TL;DR for the synthesizer
There is **no single install store today**. The §09 contract assumes *one* store powering grid + agent-pick + inline-card, with type-aware micro-states (skill Add→Adding→Added, connector Connect→Signing in→Connected token→vault, MCP Enable→Enabling→Enabled) and a workspace-scoped "N in this workspace" count bar. The actual backend splits install state across **three independent persistence layers**, none of which agree on a shared shape, and **none of which are workspace-scoped**:
| Kind | "Is it installed?" source of truth | Install verb / route | Workspace-scoped? |
|---|---|---|---|
| **skill** / **plugin** / **mcp** (marketplace pkg) | `installations` table in `marketplace.db` (`status='installed'`), keyed by **numeric `package_id`** | `POST /api/marketplace/install` | **No** — global |
| **mcp** (runtime) | `<dataDir>/.mcp.json` ⋈ live `McpRuntime` server states, keyed by **string server name** | `POST /api/mcps/install` (delegates to marketplace) | **Partial** — a server carries one optional `workspaceId` tag (C19), but install presence is global |
| **connector** | **Vault** credential under `connector:{id}` (presence = "connected"), keyed by **string connector id** | `POST /api/connectors/:id/connect` (token→vault) | **No** — vault is single-user/global |
The install-audit trail (`install_audit` table) **is the only thing that already unifies all three** as a write-side feed — but it is an append-only *log*, not a queryable "installed set," and it has **no `workspace_id` column** (schema.ts:135-152). So the count-bar's "N in this workspace" claim has **no backing data today**; it would either need a new scoping column or be redefined as a global count.
The current FE (`MarketplaceApp.tsx`) already does federate-at-read across 6 domains but holds install state only in **local React `useState`** — installing patches `extensions[]` in place; there is no cross-view store, so "installing in any view reflects in all" (the §09 CRITICAL line) is **not satisfied** today.
---
## 1. Marketplace DB schema (`packages/marketplace/src/db.ts` + types.ts)
`MarketplaceDB` wraps a `better-sqlite3` handle at `~/.waggle/marketplace.db` (overridable; the server opens `<dataDir>/marketplace.db`, seeded by copy from `packages/marketplace/marketplace.db` — server/local/index.ts:457-506). WAL mode, FK on.
### Tables (inferred from queries — there is **no `CREATE TABLE` DDL in the TS source**; the schema ships pre-built inside the committed `marketplace.db` seed file)
- **`packages`** — the catalog. Columns referenced in code (db.ts, types.ts `MarketplacePackage`): `id` (PK), `source_id` (FK→sources), `name`, `display_name`, `description`, `author`, `package_type` (`skill|plugin|mcp_server|template|pack`), **`waggle_install_type`** (`skill|plugin|mcp` — the dispatch discriminant), `waggle_install_path`, `version`, `license`, `repository_url`, `homepage_url`, `downloads`, `stars`, `rating`, `rating_count`, `category`, `subcategory`, `install_manifest` (JSON), `platforms` (JSON), `min_waggle_version`, `dependencies` (JSON), `packs` (JSON), `created_at`, `updated_at`. **Plus security columns** added by the installer's `recordScanResult()` (types.ts:81-90 `PackageSecurityColumns`): `security_status`, `security_score`, `last_scanned_at`, `content_hash`, `scan_engines`, `scan_findings`, `scan_blocked`.
- **`packages_fts`** — FTS5 virtual table joined on `p.id = fts.rowid` for full-text search (db.ts:103). `rank` column used for relevance ordering.
- **`sources`** — catalog provenance. Columns (db.ts:271-283, types.ts `MarketplaceSource`): `id`, `name`, `display_name`, `url`, `source_type`, `platform`, `total_packages`, `install_method`, `api_endpoint`, `description`, `last_synced_at`, **`is_custom`** (auto-migrated, db.ts:57-67), **`sync_state`** (auto-migrated JSON for resumable sync, db.ts:69-78).
- **`packs`** — capability bundles. Columns (types.ts `MarketplacePack`): `id`, `slug`, `display_name`, `description`, `target_roles`, `icon`, `priority` (`core|recommended|optional`), `connectors_needed` (JSON), `created_at`.
- **`pack_packages`** — pack↔package join with `is_core` flag (db.ts:191-194).
- **`installations`** — **THE install-state source of truth for marketplace packages** (db.ts:350-404). Columns (types.ts `Installation`): `id`, `package_id` (FK), `installed_version`, `installed_at`, `install_path`, `status` (`installed|updating|failed|uninstalled`), `config` (JSON — secret VALUES redacted to `[redacted]`, keys kept; installer.ts:164-173). **No `workspace_id`.**
- **`scan_history`** — append-only security scan log (installer.ts:644-659): `package_id`, `scanned_at`, `overall_severity`, `security_score`, `content_hash`, `engines_used`, `findings`, `blocked`, `scan_duration_ms`, `triggered_by`.
### Install-state API on `MarketplaceDB` (the methods a sync store would call)
- `recordInstallation(packageId, version, installPath, config)` → inserts `installations` row, `status='installed'` (db.ts:350).
- `isInstalled(packageId): boolean``SELECT 1 FROM installations WHERE package_id=? AND status='installed'` (db.ts:390). **This is the per-package installed check.**
- `markUninstalled(packageId)` → flips status to `'uninstalled'` (db.ts:400).
- `listInstallations(): InstalledPackageRow[]` → joins `installations``packages` for `status='installed'`, ordered by `installed_at DESC` (db.ts:376).
- `getInstalledCount(): number`**global** count of `status='installed'` rows (db.ts:500). This is the only existing "count bar" primitive — **not workspace-aware**.
- `getPackage(id)` / `getPackageByName(name)` — by numeric id / string name.
- `search(SearchOptions): SearchResult` — FTS5 + faceted filters; returns `{ packages, total, facets:{types,categories,sources}, installedCount }` (db.ts:86-165). `installedCount` here is the global `getInstalledCount()`.
---
## 2. List/search/install/uninstall APIs (`packages/server/src/local/routes/marketplace.ts`)
Registered as `marketplaceRoutes(fastify)` (server/local/index.ts:95). Every route gates on `fastify.marketplace` (the decorated `MarketplaceDB|null`, index.ts:506); `requireDb()` returns **503** if absent.
| Method · Route | Purpose | Tier gate | Notes |
|---|---|---|---|
| `GET /api/marketplace/search` | FTS5 + facet search of the catalog | none | Annotates each pkg with `installed: db.isInstalled(pkg.id)`, `scanStatus`, `scanScore`; appends `categories: PACKAGE_CATEGORIES` (marketplace.ts:57-118). **This is the grid's read.** |
| `GET /api/marketplace` (bare alias) | S21 alias → injects into `/search`; accepts 6-domain `type` facet; returns honest empty `{federated:true}` for non-marketplace domains | none | `extend.ts:49-85`. **This is what the current FE `adapter.getMarketplace()` calls.** |
| `GET /api/marketplace/packs` · `/packs/:slug` | List packs / pack detail | none | marketplace.ts:123-146 |
| `GET /api/marketplace/enterprise-packs` | KVARK-gated packs | `requireTier('ENTERPRISE')` | marketplace.ts:152 |
| **`POST /api/marketplace/install`** | **Install a package by numeric `packageId`** | `requireTier('PRO')` | marketplace.ts:181-432. SecurityGate pre-scan (heuristics-only) → severity gating (CRITICAL=403 always; HIGH=403 unless `force`; MEDIUM/LOW proceed) → `MarketplaceInstaller.install()` → audit rows. Returns `{...InstallResult, security:{...}}`, **200** on success / **422** on fail / **403** on block. |
| `POST /api/marketplace/uninstall` | Uninstall by `packageId` | none | marketplace.ts:437-470. For MCP packages ALSO tears down the live runtime + `<dataDir>/.mcp.json` (else it resurrects at boot). |
| `GET /api/marketplace/installed` | `listInstallations()` | none | marketplace.ts:475 |
| `POST /api/marketplace/security-check` | Scan-only by id | none | marketplace.ts:486 |
| `GET/POST/DELETE /api/marketplace/sources` | Source CRUD + sync-on-add | none | marketplace.ts:525-641 |
| `GET /api/marketplace/categories` | `PACKAGE_CATEGORIES` taxonomy (21 cats) | none | marketplace.ts:646 — feeds the category filter |
| `POST /api/marketplace/sync` | Manual sync from sources | none | marketplace.ts:655 |
| `GET /api/marketplace/security-status` | Cisco scanner availability + aggregate scan counts | none | marketplace.ts:712 |
| `POST /api/marketplace/publish` | Publish a local skill into the catalog | `requireTier('PRO')` | marketplace.ts:764 |
**Tier note for the §09 "one-click install":** `POST /api/marketplace/install` is **PRO-gated**. The skill "Add (instant)" micro-state in the contract collides with PRO-gating unless the install path for FREE-tier skills differs. The MCP path (`/api/mcps/install`) is also PRO. Connector connect (`/api/connectors/:id/connect`) is **ungated**.
---
## 3. Installer flow per type (`packages/marketplace/src/installer.ts`)
`MarketplaceInstaller.install(request: InstallRequest)` (installer.ts:81) is the **single dispatch entrypoint** for skill/plugin/mcp. Flow:
1. `db.getPackage(packageId)` → 404-shaped result if missing.
2. Idempotency: if `!force && db.isInstalled(pkg.id)` → returns success "already installed" (installer.ts:96).
3. **Security gate** (`SecurityGate.scan`) on resolved content → `recordScanResult()` → if `blocked && !forceInsecure` returns blocked result (installer.ts:116-130).
4. **Dispatch on `pkg.waggle_install_type`** (installer.ts:137):
- **`skill`** → `installSkill()` (installer.ts:309): writes `~/.waggle/skills/{name}.md` from `manifest.skill_content` / `skill_url` / repo `SKILL.md` / generated stub; then `PUT /api/skills/{name}` notify. **Instant** (matches §09 "Add→Added instant").
- **`plugin`** → `installPlugin()` (installer.ts:367): mkdir `~/.waggle/plugins/{name}/`, git-clone or `npm install`, write `plugin.json`, install bundled skills, update `registry.json`, run post-install hooks, `POST /api/plugins/install` notify. Slow / multi-step.
- **`mcp`** → `installMcp()` (installer.ts:489): optional `npm install -g`, apply settings to env, write the server entry into `<dataDir>/.mcp.json` (`mcpConfigPath()` = `WAGGLE_DATA_DIR/.mcp.json`, installer.ts:47). **Does NOT start the runtime** — that is the `/api/mcps/install` route's job (mcps.ts:277-294). (§09 "Enable→Enabling→Enabled" = install + runtime start.)
5. On success: `db.recordInstallation()` (with redacted setting keys) and attaches `scanResult`.
`uninstall(packageId)` (installer.ts:256) mirror-dispatches: `uninstallSkill` (rm file + DELETE notify), `uninstallPlugin` (rmdir + registry + DELETE), `uninstallMcp` (remove from `.mcp.json`), then `db.markUninstalled()`.
**Notify pattern:** the installer best-effort POSTs to `API_BASE` (`WAGGLE_API_URL` || `http://localhost:3000`, installer.ts:52) — fire-and-forget, swallows failure (installer.ts:764). Note the default port 3000 vs the running dev sidecar; relevant if a sync store relied on the notify hook firing.
---
## 4. The three install-state stores in detail (the "sync" problem)
The §09 CRITICAL line — *"one store powers the grid, the agent picks, AND the inline card … installing in any view reflects in all"* — has no backend equivalent. The three stores:
### 4a. Marketplace packages (skill/plugin/mcp) — `installations` table
- Truth: `MarketplaceDB.isInstalled(packageId: number)`. Keyed by **numeric package id**.
- Read surfaces: `/api/marketplace/search` (annotates `installed`), `/api/marketplace/installed`.
### 4b. MCP runtime — `<dataDir>/.mcp.json` ⋈ `McpRuntime` (`routes/mcps.ts`)
- Truth: `GET /api/mcps` (mcps.ts:143) = `MCP_CATALOG` (from `@waggle/shared`) ⋈ persisted `.mcp.json` entries ⋈ live `runtime.getServerStates()`. Keyed by **string server name**. `installed = name ∈ (persisted runtime)`.
- Statuses: `installed | running | error | stopped` (mcps.ts:67 `toInstanceStatus`).
- `POST /api/mcps/install` (mcps.ts:189) **delegates to `/api/marketplace/install`** via `fastify.inject`, then registers + `start()`s the runtime server (8s budget). So an MCP "install" touches BOTH 4a and 4b. **This is the closest thing to a working cross-store sync** — but it is MCP-specific and one-directional.
- **Workspace tag:** `PATCH /api/mcps/:id/permissions` sets a single `workspaceId` (C19 — "single-workspace scoping v1", mcps.ts:535). This is the ONLY place a "workspace" appears in install state, and it is a *scope filter on tool exposure*, not an install-presence scoping.
### 4c. Connectors — Vault (`routes/connectors.ts`)
- Truth: presence of a vault credential under `connector:{id}` (`fastify.vault.getConnectorCredential(id)`). "Connected" = credential exists & not expired.
- Read: `GET /api/connectors``connectorRegistry.getDefinitions()` (a STATIC catalog of all connectors) + `GET /api/connectors/:id/health` for live `connected|disconnected|expired|error` status.
- Install verb: `POST /api/connectors/:id/connect` — stores token→vault (matches §09 "token → vault"), re-inits the connector, writes audit. **Ungated.** Disconnect/revoke purge vault + OAuth tokens.
- **Connectors are NOT in `marketplace.db` at all.** They never appear in `installations`/`isInstalled`. The §09 grid showing connectors alongside skills/MCP must federate from `/api/connectors`.
### 4d. The unifying write-side feed — `install_audit` (the one cross-type thing that exists)
- `fastify.auditStore` (`@waggle/core` `install-audit.ts`) writes to table `install_audit` (schema.ts:135-152). All three stores already write here on install/uninstall/connect/revoke (marketplace.ts, mcps.ts `recordMcpAudit`, connectors.ts `recordConnectorAudit`).
- **Read:** `GET /api/extend/audit` (extend.ts:88) — ONE shared feed across `native|skill|plugin|mcp|connector|marketplace` with `?type=` / `?capability=` filters. The `AuditStore` API: `getRecent(limit)`, `getRecentByType(type, limit)`, `getByCapability(name)`.
- **CRITICAL GAP for the count bar:** `install_audit` has **NO `workspace_id` column** (schema.ts:135-152 — confirmed; `ai_interactions`/`execution_traces` DO have it at schema.ts:174/219, install_audit does not). It is an append-only event log, not a "current installed set." A "N in this workspace" count therefore has **zero backing data** today.
---
## 5. The install-audit trail (what's already auditable)
`RecordAuditInput` fields (used by all three route files): `capabilityName`, `capabilityType` (`native|skill|plugin|mcp|connector|marketplace`), `source`, `riskLevel` (`low|medium|high|critical`), `trustSource` (`builtin|starter_pack|local_user|third_party_verified|third_party_unverified|unknown|security-gate`), `approvalClass` (`standard|elevated|critical|blocked`), `action` (`proposed|approved|installed|rejected|failed|blocked|uninstalled`), `initiator` (`agent|user|system`), `detail`. CHECK constraints in schema.ts:143-150 enforce these — **adding a value (e.g. a `synced` action or a `workspace_id`) is a migration + CHECK change**, and the file warns these drifted once and crashed `acquire_capability` (schema.ts:139-142). Treat the audit vocabulary as a contract.
The marketplace install route writes audit rows for CRITICAL-block / HIGH-block / HIGH-force-override / MEDIUM / LOW / blocked-by-installer / forceInsecure-override (marketplace.ts:222-415) — a thorough trust trail, all global.
---
## 6. Exact integration points a PR4 "sync store" would hook into
**Reads (catalog + installed-state):**
- `GET /api/marketplace/search` (grid; annotates `installed` per pkg) — or the bare `GET /api/marketplace?type=` alias the FE already uses.
- `GET /api/marketplace/installed``MarketplaceDB.listInstallations()`.
- `GET /api/mcps` (catalog ⋈ persisted ⋈ runtime; `installed` + `status`).
- `GET /api/connectors` + `GET /api/connectors/:id/health` (connector connected-state).
- `GET /api/marketplace/categories` (the All/Skills/Connectors/MCP filter taxonomy).
- `GET /api/extend/audit` (the existing cross-type unified feed — the natural read for a "what's installed across everything" view, modulo it being a log).
**Install verbs (the three one-click flows):**
- skill / plugin → `POST /api/marketplace/install` `{ packageId }` (PRO). FE adapter: `adapter.installMarketplacePackage(packageId)``/api/marketplace/install` (adapter.ts:1339).
- mcp → `POST /api/mcps/install` `{ mcpId, settings, force }` (PRO) — delegates to marketplace install + runtime start; uninstall via `POST /api/mcps/:id/revoke`.
- connector → `POST /api/connectors/:id/connect` `{ token|apiKey }` (token→vault); disconnect `POST /api/connectors/:id/disconnect`, strong revoke `POST /api/connectors/:id/revoke`.
**Installer functions (package layer, if the store goes below the routes):**
- `MarketplaceInstaller.install(InstallRequest)` / `.uninstall(packageId)` / `.installPack(slug)` (installer.ts:81/256/210).
- `MarketplaceDB.isInstalled(id)` / `recordInstallation(...)` / `markUninstalled(id)` / `getInstalledCount()` / `listInstallations()` (db.ts).
**Server decoration:** `fastify.marketplace: MarketplaceDB | null` (index.ts:506); `fastify.connectorRegistry`, `fastify.vault`, `fastify.auditStore`, `fastify.agentState.mcpRuntime` are the sibling singletons a unified store would coordinate.
**Current FE consumer to refactor:** `apps/web/src/components/os/apps/MarketplaceApp.tsx` (417 LOC) — already federates 6 domains at read (skill via `getMarketplace`, packs, mcp via `getMcps`+`getMarketplace`, persona, connector via `getConnectors`, model, template) but holds install state in **local `useState extensions[]`**; install just patches the row (`installed: true`) — **no shared store, no cross-view propagation.** View-model federation already lives in `apps/web/src/lib/extension-catalog.ts`.
---
## 7. Key deltas vs the §09 contract (territory, not plan)
1. **No single install store.** Three stores (marketplace `installations` / `.mcp.json`+runtime / vault), three key types (numeric id / string name / string id), three install verbs, three tiers (PRO / PRO / ungated). The §09 "one store … reflects in all" is the central build.
2. **No workspace scoping of install state.** `installations` and `install_audit` have no `workspace_id`; vault is global; only MCP carries a single optional `workspaceId` *tool-scope* tag. The "N in this workspace" count bar has no data source — needs either a new scoping dimension or redefinition to a global count.
3. **No agent-suggestion endpoint.** The §09 "agent-suggestion box (connector + skill + tool, each with a why)" has no backing route. FTS search (`/api/marketplace/search`) + the agent's `acquire_capability` path (the verbose-`need` FTS handling in db.ts:91-99 / `toFtsMatchQuery`) is the nearest substrate, but a "recommend one of each kind with a reason" composite does not exist.
4. **No inline-in-chat picker route.** Variation B (mid-conversation connector offer with vault-aware approval) reuses the same install verbs but has no dedicated surfacing/approval endpoint; the connector connect flow + the existing approval/confirmation machinery (`routes/approval.ts`, agent `confirmation.ts`) are the substrate.
5. **Connectors absent from `marketplace.db`.** Any unified grid must federate connectors from `/api/connectors`, not from the marketplace catalog — they share no row shape with `MarketplacePackage`.
6. **Audit feed is a log, not a set.** `GET /api/extend/audit` unifies *events* across types but cannot answer "what is currently installed in workspace W" without scanning + reducing; the count bar wants a live set.
---
## 8. Risks / sharp edges for a builder
- **Schema DDL is invisible.** The `packages`/`sources`/`packs`/`installations`/`scan_history` tables exist only inside the committed `marketplace.db` seed binary — there is no `CREATE TABLE` in TS. Adding a `workspace_id` to `installations` means a runtime `ALTER TABLE` migration in `MarketplaceDB.migrateSchema()` (the established pattern, db.ts:57-78), not an edit to a schema file.
- **`install_audit` CHECK constraints are a hard contract.** Adding a `synced` action or any new vocabulary needs a coordinated `runMigrations()` + CHECK rewrite; schema.ts explicitly records a prior drift that crashed `acquire_capability`.
- **PRO-gating vs "instant Add".** Skill install is PRO-gated server-side; the §09 instant micro-state must reconcile with `requireTier('PRO')` (or rely on a different non-gated skill path).
- **MCP install is the only working cross-store coupling** (`/api/mcps/install` writes both `installations` and `.mcp.json`+runtime, and revoke keeps marketplace `installed` honest, mcps.ts:509-519). A unified store should mirror this bidirectional bookkeeping for the other types, or it will drift (the FE will show "installed" after a revoke, etc.).
- **OSS-sync constraint (§7.5):** `install_audit` DDL inside `mind/schema.ts` is OSS-excluded (Waggle governance). A `workspace_id` addition there has nowhere to land on the public mirror — fine for the monorepo, but flag it as a curated-strip item.
- **Three-store consistency under failure.** The installer's `notify` hook is fire-and-forget to `localhost:3000` (not the running sidecar port); a sync store cannot rely on the notify callback to invalidate caches.
---
## 9. Open questions for the founder/lead
1. **Workspace scoping:** Is "N in this workspace" a real per-workspace install set (requires a new `workspace_id` on installs + per-workspace activation model), or a relabeled global count for v1? This is the single biggest decision — it determines whether the sync store needs a new data dimension across all three backends.
2. **Skill install tier:** Should the §09 instant skill "Add" stay PRO-gated, or is there a FREE skill-install lane? (CLAUDE.md moat: skills/connectors are the upgrade trigger — so PRO-gating may be intentional and the "Add" CTA should show the upgrade nudge instead of installing.)
3. **Agent-suggestion box:** Build a new composite recommend endpoint (one connector + one skill + one tool + "why"), or compose it client-side from three `search` calls + an LLM rationale? No backing route exists either way.
4. **Sync store location:** A FE-only store (React context / Zustand over the existing routes) vs a new server-side unified `/api/install-state` read that reduces all three stores into one shape. The §09 "one store reflects in all" is achievable purely client-side IF every view subscribes to it; a server read is only needed for the count bar's correctness across reloads.
5. **Inline-in-chat (Variation B):** Reuse `/api/connectors/:id/connect` + existing approval machinery, or a dedicated in-chat install/approval contract? Decide before building the chat surface.

View File

@@ -0,0 +1,370 @@
# PR4 Recon · Slice 3 — Shared Install State ("sync")
**Branch:** `feature/warm-hive-pr4` · **Screen:** 09 Marketplace · **Read-only recon.**
**Design contract:** `docs/design_handoff_waggle_app/SCREENS.md` §09 (lines 189-207) +
the reference implementation in `docs/design_handoff_waggle_app/design-files/screens/marketplace.html`
(lines 203-310 — the canonical `installed`/`installing` Set + `renderAll()` "sync" model).
> This is THE critical design element of PR4: "one store powers the grid, the agent picks,
> AND the inline card. Installing in any view reflects in all of them."
---
## 0. TL;DR — the delta in one paragraph
There is **no shared install state today.** Three install backends exist and are battle-tested,
but each lives in its own server module with its **own persistence mechanism and its own
notion of "installed"**: skills = markdown files on disk reloaded into `agentState.skills`;
connectors = vault credentials; MCP = `.mcp.json` + a live runtime. The FE has **no unified
store** — `MarketplaceApp` (the existing Phase-4B Extend surface) federates the six facets
**at read** into a local `useState<Extension[]>` and **discards that view on unmount**. Installing
in the grid does NOT reflect in chat's `CapabilityRequestCard` (which keeps its own local
`phase` state) and vice-versa. There is **no count bar**, **no type-aware Add/Connect/Enable
verbs**, and **no progress→done micro-states** on the cards. The single cross-type fact that
IS already shared is the **install-audit trail** (`server.auditStore`, the `install_audit`
table) — every install path writes to it, but it is an append-only event log, not a queryable
"installed set." PR4 must introduce a real FE store + a thin server aggregate so the three
backends present as ONE reactive installed set.
---
## 1. Where install state lives TODAY — per type
### 1a. Skills — filesystem-backed
**Server:** `packages/server/src/local/routes/skills.ts`
- **Source of truth:** `.md` files in `<dataDir>/skills/` (default `~/.waggle/skills/`).
"Installed" = file exists on disk; "active" = also loaded into `server.agentState.skills`.
- **Install:** copies starter/pack `.md` into `skillsDir`, then reloads
`server.agentState.skills.length = 0; push(...loadSkills(waggleHome))` (skills.ts:204-205,
333-334). Authored skills go through `writeSkill(...)` (the P5/D4 shared write-service that
redacts + stamps provenance + audits) at skills.ts:439, 500, 545.
- **Routes:**
- `POST /api/skills/starter-pack/:id` (skills.ts:168) — single starter skill, instant
- `POST /api/skills/capability-packs/:id` (skills.ts:271) — install a whole pack
- `POST /api/skills/:id/install` — alias in `skills-aliases.ts` (the one the FE adapter
`installSkill(id, source, packageId?)` actually calls — adapter.ts:1240-1256)
- `DELETE /api/skills/:name` (skills.ts:563) — uninstall (audits `'uninstalled'`)
- `GET /api/skills` (skills.ts:345) — list installed (with provenance/initiator)
- `GET /api/skills/capability-packs/catalog` (skills.ts:238) — packs with per-skill
`state: 'active'|'installed'|'available'` and a `packState`/`installedCount`/`totalCount`
- **Audit:** `server.auditStore.record({ capabilityType: 'skill', action: 'installed'|'uninstalled', ... })`
- **Micro-state in design:** `skill``Add → Adding… → Added` (instant; ~480ms in the mock).
### 1b. Connectors — vault-credential-backed
**Server:** `packages/server/src/local/routes/connectors.ts`
- **Source of truth:** the **Vault** (`packages/core/src/vault.ts`). "Installed/connected" =
a credential exists under `connector:{id}` (`fastify.vault.setConnectorCredential`,
connectors.ts:122). Status is derived **live** by `connectorRegistry.healthCheck(id)` /
credential presence — there is no stored "installed" boolean.
- **Connect (the design's "token → vault"):** `POST /api/connectors/:id/connect`
(connectors.ts:96) stores the credential in the vault, re-inits the connector, and audits
`action:'installed', trustSource:'local_user'`. OAuth tokens are also written by
`oauth.ts` under `${provider}_oauth_token` (provider-keyed, NOT connector-keyed — see the
`OAUTH_PROVIDER_FOR_CONNECTOR` Google-family map at connectors.ts:16-23).
- **FE connect flow (the real vault path):** `ConnectorsApp.tsx`
`adapter.addVaultSecret({ key: 'connector:{id}', value: token, type:'bearer' })` THEN
`adapter.connectConnector(id)` (ConnectorsApp.tsx:154-155).
- **Routes:** `GET /api/connectors` (list+status, connectors.ts:38), `/connect`, `/disconnect`,
`/sync` (C16 health re-probe + `lastSyncAt` stamp), `/revoke` (C17 strong purge incl. OAuth).
- **Adapter:** `getConnectors()` (adapter.ts:1958), `connectConnector(id, creds?)` (1971),
`disconnectConnector` (1981), `syncConnector` (1986), `revokeConnector` (1994).
- **Micro-state in design:** `connector``Connect → Signing in… → Connected` (~1.1s, the
"token goes to your vault" approval). **This is the only flow with an approval gate** in
Variation B (the vault-aware "Add & connect" approval, marketplace.html:179-183).
### 1c. MCP — `.mcp.json` + live runtime
**Server:** `packages/server/src/local/routes/mcps.ts`
- **Source of truth:** persisted `<dataDir>/.mcp.json` (`mcp-config.ts`) **⋈** the live
`McpRuntime` server states (`fastify.agentState.mcpRuntime`). "Installed" = present in
`.mcp.json` OR registered in the runtime (mcps.ts:147). The C4 boot loader re-registers
persisted entries so installs survive restarts.
- **Install:** `POST /api/mcps/install` (mcps.ts:189, **PRO-gated** via `requireTier('PRO')`)
**delegates to the marketplace installer** (`fastify.inject('/api/marketplace/install')`) so
SecurityGate + scan + install_audit ride along, then `saveMcpServerEntry` + `runtime.addServer`
+ `instance.start()`. Custom servers: `POST /api/mcps` (mcps.ts:331, also PRO-gated).
- **Enable/disable:** `POST /api/mcps/:id/start` (mcps.ts:471), `/stop` (487), `/revoke` (496,
removes from runtime + config + marks the marketplace package uninstalled).
- **Routes/list:** `GET /api/mcps` (mcps.ts:143) returns `{ mcps, total, installed }` where
each row carries `installed`, `status`, `scope`, `state`, `tools`.
- **Adapter:** `getMcps()` (adapter.ts:2004), `installMcp(mcpId, opts)` (2017),
`addCustomMcp` (2031), `testMcp` (2045).
- **Micro-state in design:** `mcp``Enable → Enabling… → Enabled` (~720ms in the mock).
### 1d. Marketplace registry (the spine the others lean on)
**Server:** `packages/server/src/local/routes/marketplace.ts` (uses `@waggle/marketplace`
`MarketplaceDB`/`MarketplaceInstaller`/`SecurityGate`). The `packages` table is SQLite; a
package's domain is `waggle_install_type` (`'skill'|'mcp'|'plugin'`), NOT a `type` column.
- `GET /api/marketplace/search` (`installed: db.isInstalled(pkg.id)`, marketplace.ts:107)
- `POST /api/marketplace/install` (marketplace.ts:181, PRO-gated; SecurityGate + audit)
- `POST /api/marketplace/uninstall` (marketplace.ts:437; MCP cleanup ride-along)
- `GET /api/marketplace/installed` (marketplace.ts:475 → `db.listInstallations()`)
- `GET /api/marketplace/packs` (raw `MarketplacePack` rows — **no install route exists**, so
packs are browse-only today; see `fromSkillPack` `installable:false`)
- **Adapter:** `getMarketplace({query,type,limit})` (adapter.ts:2082), `searchMarketplace`
(1334), `installMarketplacePackage(packageId)` (1338, returns raw Response),
`uninstallMarketplacePackage` (1345), `getMarketplacePacks` (1309).
---
## 2. The ONLY thing shared today: the install-audit trail
`server.auditStore` (the `install_audit` table) is the single cross-type surface every install
path already writes to:
- skills: skills.ts:212, 315 + the `writeSkill`/`deleteSkill` service
- connectors: connectors.ts `recordConnectorAudit` (29) on connect/sync/revoke
- mcp: mcps.ts `recordMcpAudit` (93) on install/custom-add/revoke
- marketplace: writes its own rows on non-clean scans
It is exposed as `GET /api/audit/installs` (skills.ts:718) and a shared Extend feed
`GET /api/extend/audit?type=…` (rendered by `extend/InstallAuditPanel.tsx`, used as the
Marketplace "Audit" tab). **But it is an append-only EVENT log, not a queryable installed-set**
— you cannot ask it "what is installed right now" without replaying install/uninstall pairs.
`install_audit` is also **OSS-EXCLUDED** (CLAUDE.md §7.5) — fine for a Waggle-only count, but it
must NOT become the substrate's source of truth.
> **No skill_share/diffusion wiring lives in the server `local/` layer.** `lifecycle.ts` has
> none; the `onSkillDistillationFire → skill_share` callback is the agent layer (CLAUDE.md §10
> Phase 3). `waggle-dance-bridge.ts` only *categorizes* an incoming `skill_share` subtype into
> the `'handoff'` UI bucket (waggle-dance-bridge.ts:53). Diffusion is **out of scope for the
> install-sync store** — it is a separate signal stream (relevant to screen 12, not 09).
---
## 3. The FE today — `MarketplaceApp` federates at READ, holds no shared state
`apps/web/src/components/os/apps/MarketplaceApp.tsx` (routed at `/marketplace` via
`MarketplaceRoute.tsx`; mounted bare in Desktop) is the existing Phase-4B six-facet Extend
surface. How it works today:
- `loadFacet(f, q)` (MarketplaceApp.tsx:116) fires **N parallel adapter calls** (one per facet)
and merges results into a **local** `useState<Extension[]>` (line 105) via the pure
normalizers in `lib/extension-catalog.ts`.
- Install dispatch (`handleInstall`, MarketplaceApp.tsx:224) **only works for
`kind:'package'`** (marketplace registry rows). Connectors, MCP catalog rows, agents,
models, templates are `kind:'federated'``installable:false` → render an **"Open in
<app>"** deep-link, NOT an install button (`ExtensionCard.tsx:72`). Skill *packs* are
`installable:false` too (no install-pack route).
- On a successful install it mutates ONLY its own local array
(`setExtensions(prev => prev.map(...installed:true))`, line 231). **Nothing else on the
screen or app knows.** Unmount = state gone, re-fetch on next mount.
### Card today vs. design
`ExtensionCard.tsx` shows a generic **`Install` / `Remove`** button + a static
`Installed`/`Available` `StatusBadge`. It has **none** of:
- type-aware verbs (`Add`/`Connect`/`Enable`) — design `VERB` map, marketplace.html:216
- a per-item `installing` micro-state (`Adding…`/`Signing in…`/`Enabling…`)
- a `+`/`✓` affordance keyed off a shared installed Set
### Variation B today — `CapabilityRequestCard` (a partial, divergent precursor)
`apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` already surfaces an
**inline install affordance in chat**, parsed out of agent text by
`capability-request-parser.ts` (a `<!--waggle:capability_request {...}-->` marker or a legacy
`install_capability with name "X"` phrasing). BUT:
- It handles **only `skill` (starter-pack) and `marketplace`** kinds — **no connector vault
approval, no MCP enable** (the design's headline Variation-B example is a *Salesforce
connector* with the "token → vault" approval, which this card cannot render).
- It owns its **own local `phase` state** (`'pending'|'installing'|'installed'|...`, line 17/32)
— completely disconnected from the grid. Installing here never updates the grid's count bar
or card; installing in the grid never flips this card to "installed."
- It is **not vault-aware** and has no "token goes to your vault" approve-row.
So: two install UIs, two private states, three backends, zero shared store, no count bar.
---
## 4. What "one store + cross-view reactivity" requires
### 4a. The design's reference model (what we must reproduce, for real)
`marketplace.html` is the spec in code: a single module-level `installed: Set` + `installing:
Set`, a `btnHTML(item)` that reads those Sets for `[idle, in-progress, done]` per `item.k`
(`VERB` map), ONE delegated click handler (`document.addEventListener('click', … install(id))`),
and a `renderAll()` that re-paints **grid + picks + count-bar + inline-chat card** off the same
two Sets. `install(id)` flips `installing→installed` with a type-keyed delay, fires a toast, and
re-renders everything. The inline-chat `renderInline()` (line 299) reads the SAME `installed`
Set — that is the entire "sync" mechanism.
### 4b. FE store shape (the new artifact PR4 introduces)
A React context/store — call it `InstallStore` (the BUILD-PLAN's "new install store") — holding:
- `installed: Map<extKey, InstalledRecord>` and `installing: Set<extKey>` where `extKey` is the
existing namespaced id (`extension-catalog`'s `connector:slack`, `mcp:fs`, `pkg:42`,
`skill:teardown`) so it federates across types without collision.
- `install(ext)`**type-aware dispatcher** routing to the right adapter call:
- `skill``adapter.installSkill(id, source, packageId?)` / `installPack` (instant)
- `connector` → vault approval → `addVaultSecret` + `connectConnector(id)` (~1.1s, gated)
- `mcp``adapter.installMcp(mcpId)` (PRO-gated; SecurityGate)
- `package` (registry) → `adapter.installMarketplacePackage(packageId)`
optimistically add to `installing`, on success move to `installed` + toast, on failure roll back.
- A subscription so the grid, the agent-pick box, the count bar, AND the chat
`CapabilityRequestCard` all read the same Sets and re-render on change (the cross-view
reactivity = React context consumers, replacing today's per-component `useState`).
- Pattern to match: this codebase already uses small custom contexts (`ServiceProvider`,
`ShellContext`, `ThemeProvider` in `apps/web/src/providers/`) and per-domain hooks
(`useChat`, `useMemory`, `useWorkspaces` in `apps/web/src/hooks/`). The install store should
be a sibling provider + a `useInstallStore()` hook — **no Redux/Zustand precedent in this repo.**
### 4c. Server aggregate (the gap)
The store needs an **initial installed-set hydrate** and a **count**. Today that means fanning
out the same N calls `MarketplaceApp.loadFacet` already does (`/api/skills`,
`/api/connectors`, `/api/mcps`, `/api/marketplace/installed`) and merging. Options for the
"N in this workspace" bar:
- **Cheap path (no new route):** derive the count/chips on the FE from the existing per-type
list endpoints the store already calls (each returns an `installed` flag/`status`). This is
the surgical, ship-now option.
- **Aggregate route (nicer):** a new `GET /api/extend/installed` (or `/api/marketplace/sync`)
returning `{ items:[{key,type,name,installed}], count }` by merging the three backends server-
side. The Marketplace already owns an "Extend" namespace (`extend.ts`, the audit feed); this
would slot beside it. Mind the §7.5 mind-isolation rule: keep it workspace-scoped, no cross-
mind reads.
### 4d. The three micro-state flows differ structurally — the store must encode that
| type | adapter call | gate / approval | latency profile | persistence |
|---|---|---|---|---|
| skill | `installSkill`/`installPack` | none (bundled) | instant (~480ms) | FS file + `agentState` reload |
| connector | `addVaultSecret`+`connectConnector` | **vault approval** ("token → vault"); may need OAuth | ~1.1s (sign-in) | vault credential |
| mcp | `installMcp` | **PRO tier + SecurityGate** scan | ~720ms (spawn/enable) | `.mcp.json` + runtime |
| package (registry skill/mcp) | `installMarketplacePackage` | **PRO tier + SecurityGate** | varies | marketplace.db install row |
The store's `install()` cannot be one uniform call — it is a switch on `ext.type`/`ext.kind`,
each branch with its own optimistic/confirm/rollback semantics. Connector is the only branch
that surfaces an interstitial approval before the optimistic flip.
---
## 5. Concrete deltas vs. the screen-09 contract
1. **No shared install store at all** — grid, agent-picks, and inline-chat each hold private
state (or none). The store + cross-view reactivity is net-new (the BUILD-PLAN's "new install
store").
2. **No count bar** — the design's "N in this workspace" chip bar (`ibCount`/`ibChips`,
marketplace.html:151-155) has no FE or server surface today.
3. **Cards lack type-aware verbs + micro-states**`ExtensionCard` shows generic
`Install`/`Remove`; design wants `Add/Connect/Enable` × `idle/in-progress/done`.
4. **Connectors & MCP are not installable from the grid today** — they render as federated
"Open in <app>" deep-links (`installable:false`). The design wants a one-click
Connect/Enable IN the grid that hits the real vault/runtime paths.
5. **Variation B is partial**`CapabilityRequestCard` exists but handles only skill+marketplace,
owns disconnected local state, and has **no vault-aware connector approval** (the literal
headline example of Variation B).
6. **No centered agent-search "Ask the agent" + suggestion box** — MarketplaceApp has a plain
filter search, not the "Describe what you want to do…" agent-pick surface with
connector+skill+tool recommendations and "why" reasons. (This is the Variation-A search half;
Slice-3's job is the *install state* under it, but the picks must read the same store.)
7. **`installable:false` on skill packs** — no install-pack route server-side; either the store
skips packs or PR4 adds the route.
---
## 6. Integration points a PR4 build would touch
**FE (new + edit):**
- NEW: `apps/web/src/providers/InstallProvider.tsx` (or `hooks/useInstallStore.ts`) — the shared
Set-based store + type-aware `install()` dispatcher + count selector. Mount alongside
`ServiceProvider`/`ShellContext`.
- EDIT: `apps/web/src/components/os/apps/MarketplaceApp.tsx` — read installed/installing from the
store instead of local `useState`; add the count bar + agent-search/picks; route card installs
through the store.
- EDIT: `apps/web/src/components/os/apps/extend/ExtensionCard.tsx` — type-aware verbs +
`idle/in-progress/done` micro-states off the store.
- EDIT: `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` +
`capability-request-parser.ts` — extend the `CapabilityRequest.kind` union to
`'connector'|'mcp'` (with the vault approval row for connector), read/write the SHARED store.
- REUSE: `apps/web/src/lib/extension-catalog.ts` (`Extension`, namespaced ids, `fromConnector`/
`fromMcpCatalogRow`/`fromMarketplacePackage`/`fromSkillPack`) — the existing federation
normalizers are the store's value type; today they hard-code `installable:false` for
connectors/MCP — those flip true once the store can install them.
**FE adapter (already present — wire, don't recreate):** `adapter.installSkill` /
`installPack` / `connectConnector` / `addVaultSecret` / `installMcp` /
`installMarketplacePackage`; list/hydrate via `getSkills`/`getConnectors`/`getMcps`/
`getMarketplace`/`getMarketplaceInstalled` equivalents.
**Server (mostly reuse; one optional new route):**
- REUSE: `POST /api/skills/:id/install` (skills-aliases.ts), `POST /api/connectors/:id/connect`
(connectors.ts:96), `POST /api/mcps/install` (mcps.ts:189), `POST /api/marketplace/install`
(marketplace.ts:181) — the install verbs already exist and audit.
- REUSE for hydrate: `GET /api/skills`, `GET /api/connectors`, `GET /api/mcps`,
`GET /api/marketplace/installed`.
- OPTIONAL NEW: `GET /api/extend/installed` (aggregate installed-set + count) beside
`extend.ts` — only if the FE doesn't derive the count from the per-type lists. Keep
workspace-scoped (CLAUDE.md §7.5 mind-isolation).
- DO NOT TOUCH: the install-audit substrate as a source of truth (`install_audit` is OSS-excluded
+ append-only).
**Shared types:** `packages/shared/src/types.ts` `EXTENSION_TYPES`/`ExtensionType` (line 375) is
the facet vocabulary the store keys on — no change needed unless a `package` vs domain
discriminator is wanted.
---
## 7. Risks
- **Three backends, three "installed" definitions** — FS file vs vault credential vs
`.mcp.json`⋈runtime. A naive single boolean store will drift from reality (e.g. an MCP that
failed to `start()` is persisted-but-not-running). The store's record must carry enough state
(`status`/`state`) to stay honest, mirroring `getMcps`'s `status` field.
- **Tier + SecurityGate gating is real** — MCP and marketplace installs are PRO-gated and can be
scan-blocked (403/422 with `{requiresApproval, blocked, severity}`). The store's optimistic
flip must roll back on these and route to the UpgradeModal/ApprovalModal, exactly as
`MarketplaceApp.handleInstall` (line 238) and `MCPHubApp` already do. Don't let the count bar
show an item that the gate rejected.
- **Connector OAuth complexity** — the "Connect → Signing in…" flow may be a real OAuth redirect
(oauth.ts) for some connectors, not just a token paste. The ~1.1s mock latency hides a
potentially multi-step, navigation-away flow; the store needs a pending/await state that
survives that.
- **Reflecting external installs** — installs done in the dedicated apps (`ConnectorsApp`,
`MCPHubApp`, skills center) must also update the shared store, or the count bar lies. Either
those apps adopt the store too, or the store re-hydrates on focus/navigation.
- **Audit-vocabulary mismatch** — `install_audit`'s action enum has no `'enabled'`/`'connected'`/
`'synced'` verbs (connectors map sync→`'approved'`, connectors.ts:225). If the store ever reads
the audit feed for state, it inherits this lossiness. Read from the per-type list endpoints
instead.
- **`apps/web` is the only typechecked surface** (CLAUDE.md §2) — a new provider with subtle
types is fine, but any server aggregate route runs under `tsx` transpile-only and won't be
typechecked by `npm run build`; run `tsc -p packages/server` explicitly.
---
## 8. Open questions for the founder/lead
1. **Aggregate route vs FE-derived count** — ship the count bar by deriving from the existing
per-type list endpoints (zero new server surface, fastest), or add a real
`GET /api/extend/installed`? (Recommend FE-derive for PR4; promote to a route only if reused.)
2. **Do the dedicated apps adopt the store too?** The "installing in ANY view reflects in ALL"
contract technically includes `ConnectorsApp`/`MCPHubApp`/skills center, not just the three
Marketplace surfaces. In-scope for PR4, or PR4 covers grid+picks+inline only and the store
re-hydrates on nav?
3. **Connector install in-grid = real OAuth?** Some connectors are token-paste (vault), some are
OAuth redirect. Does the grid's one-click "Connect" do the full sign-in inline, or open the
Connector Hub for OAuth ones while doing token-paste inline? (Affects the micro-state UX.)
4. **Skill packs installable?** They are browse-only today (no install-pack route). Add a real
`POST /api/marketplace/install-pack` for PR4, or keep packs browse-only and have the store
skip them?
5. **Agent-pick search backing** — is the "Ask the agent" suggestion box a real agent call
(`/api/command` / the agent loop) returning connector+skill+tool picks, or a heuristic FE
matcher like the mock's `recs` map? (Slice 3 only owns that the picks read the shared store;
the recommendation engine itself may be another slice.)
6. **PRO-gating in the count** — should PRO-gated items (MCP, marketplace) appear installable to
FREE users with an upsell on click, or render gated up front? (`MarketplaceApp` currently
lets the click 403 → UpgradeModal.)
---
## 9. Key files (quick index)
| path | role |
|---|---|
| `docs/design_handoff_waggle_app/SCREENS.md` §09 (189-207) | the screen-09 contract |
| `docs/design_handoff_waggle_app/design-files/screens/marketplace.html` (203-310) | canonical "sync" reference impl (installed/installing Sets + renderAll) |
| `apps/web/src/components/os/apps/MarketplaceApp.tsx` | existing Extend grid — federates at read, local state only |
| `apps/web/src/lib/extension-catalog.ts` | pure facet normalizers + namespaced ids = the store's value type |
| `apps/web/src/components/os/apps/extend/ExtensionCard.tsx` | grid card — generic Install/Remove, needs type-aware verbs |
| `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` | Variation-B precursor — skill+marketplace only, private state, no vault approval |
| `apps/web/src/components/os/apps/chat-blocks/capability-request-parser.ts` | inline-card parser (marker + legacy phrasing) |
| `apps/web/src/lib/adapter.ts` (1240/1338/1958/2004 …) | all install/list verbs already exist |
| `packages/server/src/local/routes/skills.ts` | skills install (FS files + agentState reload + audit) |
| `packages/server/src/local/routes/connectors.ts` | connectors install (vault credentials + audit) |
| `packages/server/src/local/routes/mcps.ts` | MCP install (.mcp.json + runtime, PRO+SecurityGate) |
| `packages/server/src/local/routes/marketplace.ts` | registry install/uninstall/installed + SecurityGate |
| `packages/server/src/local/routes/extend.ts` + `extend/InstallAuditPanel.tsx` | shared install-audit feed (the ONLY cross-type surface today) |
| `packages/server/src/local/waggle-dance-bridge.ts` (53) | only categorizes incoming skill_share → not install-sync |
| `packages/shared/src/types.ts` (375) | `EXTENSION_TYPES` facet vocabulary |
| `apps/web/src/providers/{ServiceProvider,ShellContext,ThemeProvider}.tsx` | the context pattern the new InstallProvider should follow |

View File

@@ -0,0 +1,348 @@
# PR4 Recon — Slice 4: Inline-in-Chat Install (Marketplace Variation B)
> **Scope:** Screen 09 Marketplace, **Variation B** — Waggle offers a *missing connector*
> mid-conversation with a vault-aware approval ("token goes to your vault"), then shows the
> connected follow-up. Map the chat step/block rendering, the approval/confirmation infra,
> and the vault-write path; identify the exact hook points a PR4 build would touch.
>
> **READ-ONLY recon.** No source modified. Citations are `file:line` against
> `feature/warm-hive-pr4` (working tree at recon time).
---
## 1. The design contract (what Variation B must do)
From `docs/design_handoff_waggle_app/SCREENS.md:199-207`:
- **Variation B (Inline in chat):** "the same picker surfaced mid-conversation — Waggle
offers a missing Salesforce connector with a vault-aware approval ('token goes to your
vault'), then shows the connected follow-up."
- **CRITICAL — shared install state ("sync"):** "one store powers the grid, the agent picks,
AND the inline card." Type-aware one-click flows with progress→done micro-states:
- **skill** Add→Adding…→Added (instant)
- **connector** Connect→Signing in…→Connected (~1.1s, **token→vault**)
- **MCP** Enable→Enabling…→Enabled
- Each fires a toast + updates the count bar. **Installing in any view reflects in all.**
PR4 BUILD-PLAN row (`docs/redesign-warm-hive/BUILD-PLAN.md:142`):
`PR4 | Marketplace + shared install store ("sync") (grid + agent-pick + inline-in-chat) | MarketplaceApp, new install store | 09`
So Variation B is **one of three consumers** of a single shared install store. This slice maps
the *chat-side* surface (how the agent raises the offer mid-turn, how the FE renders the inline
card, and how approval → connect → vault wires). The grid + store itself is the broader PR4 build.
---
## 2. End-to-end map of the chat turn (what exists today)
### 2.1 SSE event protocol (server → FE)
`POST /api/chat` (`packages/server/src/local/routes/chat.ts:351`) is the SSE endpoint. It hijacks
the reply (`chat.ts:462`) and writes events with `sendEvent(event, data)` (`chat.ts:475-477`):
```
event: <name>\ndata: <JSON>\n\n
```
Event names emitted today (the wire vocabulary the FE switches on):
`step`, `tool` / `tool_result` (auto_recall only), `token`, `gepa_choices`, `model_switch`,
`approval_required`, `done`, `error`. (Note: the agent loop itself emits `tool_start`/`tool_end`
for real tool calls — see the FE switch in §2.3 — via the agent-loop callbacks, not direct
`sendEvent` in chat.ts.)
The **approval handshake** is the load-bearing primitive for Variation B. It lives in a
per-request `pre:tool` hook registered at `chat.ts:909-1045`:
1. A tool reaches the gate; `needsConfirmationWithAutonomy(toolName, args, autonomyLevel)`
decides if it gates (`chat.ts:916`).
2. The hook enriches the request with **risk metadata**`install_capability` gets a
content-based `assessTrust()` (`chat.ts:960-982`), every other gated tool gets
`classifyGatedToolRisk()` (`chat.ts:989-999`). `description` comes from
`describeToolUse(toolName, input)` (`chat-helpers.ts:106`).
3. It emits `sendEvent('approval_required', { requestId, toolName, input, sourceWorkspaceId,
...trustMeta })` (`chat.ts:1005-1009`).
4. It **blocks** on `new Promise<boolean>` registered in `server.agentState.pendingApprovals`
keyed by `requestId` (`chat.ts:1020-1036`), with a 5-min auto-deny timeout.
5. The FE posts the decision to `POST /api/approval/:requestId`
(`packages/server/src/local/routes/approval.ts:10`), which calls `pending.resolve(approved)`
(`approval.ts:31`) → the hook returns `{ cancel: true }` on deny (`chat.ts:1041`) or lets the
tool run on approve.
This approval handshake is **exactly the mechanism Variation B needs** — a server-side pause
mid-turn that surfaces an FE card and resumes on the user's click.
### 2.2 Block model (the FE message content type)
`apps/web/src/lib/types.ts:484-532` — `ContentBlock` is a discriminated union of:
`TextContentBlock` | `StepContentBlock` | `ToolUseContentBlock` | `ModelSwitchContentBlock` |
`ErrorContentBlock`. There is **no approval/install block type** — approvals are kept *out* of
the block stream (see §2.4) and rendered as a separate singleton.
`StepContentBlock` (`types.ts:497-508`) already carries an **optional `provenance: { sources }`**
field (PR3.5) — the precedent for type-specific metadata riding on a step block.
### 2.3 SSE → block reduction (`useChat`)
`apps/web/src/hooks/useChat.ts:125-273` consumes the SSE stream and reduces events into
`blocks[]` on the last assistant message. The relevant cases:
- `step` → pushes a `StepContentBlock` (`useChat.ts:152-175`), copying `data.provenance.sources`.
- `tool_start` / `tool_end` → push/patch a `ToolUseContentBlock` (`useChat.ts:177-222`).
- `approval_request` / `approval_required` → `setPendingApproval(data as ApprovalRequest)` and
**`return msgs`** — i.e. deliberately does NOT add a block (`useChat.ts:258-263`).
- `approveAction(requestId, approved, { always })` → `adapter.respondApproval(...)` then
`setPendingApproval(null)` (`useChat.ts:317-333`).
So today there is **one** pending approval at a time, held in component state, not in the block
stream. The hook returns `{ messages, isLoading, sendMessage, clearHistory, pendingApproval,
approveAction }` (`useChat.ts:335`).
### 2.4 Block rendering
`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:53-104` walks `blocks[]`:
- consecutive `step` blocks are grouped into one collapsible **`ActivityStream`** card
(`BlockRenderer.tsx:30-51`) — the "magic" surface.
- `tool_use` blocks route to **`ArtifactBlock`** (completed file-writes — `ArtifactBlock.tsx:21-28`
is the routing predicate `isArtifactBlock`) or the generic `ToolUseBlock`.
- `text` / `model_switch` / `error` each have a renderer.
`ArtifactBlock.tsx` is the **closest existing precedent** for what an inline install card should be:
a tool-result that, when completed, renders as a rich actionable card (icon + name + button) instead
of a debug row. An inline-install card is the same pattern for a *connector/skill/MCP* tool result.
### 2.5 Approval rendering (the inline card that exists)
`ChatApp` renders the singleton approval **inside the message list** at the bottom:
`{pendingApproval && <ApprovalGate request={pendingApproval} onRespond={onApprove} />}`
(`ChatApp.tsx:1213-1215`).
**`ApprovalGate`** (`ChatApp.tsx:225-311`) is already an in-thread, warm-token card:
`--honey-wash` fill + attention border + `AlertTriangle` + a `RiskBadge` + `description` + a
mono `toolName inputSummary` line + `trustSource` + Approve / Always-allow / Not-now buttons +
a "Show details" JSON toggle. It is **generic** (driven by `ApprovalRequest`), not connector-aware.
There is also a **reusable warm primitive** `InlineApprovalCard`
(`apps/web/src/components/os/warm/InlineApprovalCard.tsx`, exported from `warm/index.ts:19`) — a
cleaner card that takes an `ApprovalRequest`, `title`, `onApprove`/`onDecline`/`onAlwaysAllow`,
and an `approveLabel`. Its **default title is literally "Approve before I leave your machine"** —
already in the vault-aware register Variation B wants. NOTE: `ApprovalGate` (the one actually
wired in ChatApp) and `InlineApprovalCard` are **two parallel implementations of the same idea**;
both share the risk vocabulary via `lib/risk-display.ts`. A build should pick one.
`ApprovalRequest` shape (`apps/web/src/lib/types.ts:410-430`): `{ requestId, toolName, description,
input, rawJson?, sourceWorkspaceId?, riskLevel?, approvalClass?, trustSource?, assessmentMode?,
explanation?, permissions? }`. All of these are already on the wire from `chat.ts:1005-1009`.
---
## 3. The install / connect / vault paths (what an "approve" must trigger)
There are **three different install backends today**, none of which is currently reachable as a
single agent-loop "offer a missing connector" tool:
### 3.1 Skill install — agent-loop reachable (the only one that is)
`packages/agent/src/skill-tools.ts:480-668` — the `install_capability` tool. It copies a curated
starter-skill `.md` into the skills dir (`skill-tools.ts:631-633`), runs a `SecurityGate` scan,
records install-audit rows, hot-reloads via `onSkillsChanged()`, and returns the skill content.
**No vault.** This is the "skill: Add→Adding…→Added (instant)" lane. It already flows through the
chat approval gate (it is in `ALWAYS_CONFIRM`, `confirmation.ts:19`) and gets the **richest** trust
metadata on the approval event (`chat.ts:960-982`).
### 3.2 Connector connect — HTTP route, NOT agent-loop reachable
`POST /api/connectors/:id/connect` (`packages/server/src/local/routes/connectors.ts:96-156`):
- validates the connector exists in `connectorRegistry`,
- requires `token` or `apiKey` in the body,
- **writes the token to vault**: `fastify.vault.setConnectorCredential(id, { type, value,
refreshToken, expiresAt, scopes })` (`connectors.ts:122-128`) — **this is the "token→vault"
step the design names**,
- re-initializes the connector (`connector.connect(fastify.vault)`, `connectors.ts:138`),
- records an install-audit row (`connectors.ts:145-153`).
FE adapter: `adapter.connectConnector(id, credentials)` → POSTs that route
(`apps/web/src/lib/adapter.ts:1971-1979`).
**There is no agent tool that calls this.** The agent can only *discover* connectors via
`find_connector` / `list_connector_categories` (`packages/agent/src/connector-search.ts:161-265`),
which return catalog JSON (name, installCmd, url) as text — the agent literally cannot connect one.
### 3.3 Connector OAuth — separate browser-redirect flow
`packages/server/src/local/routes/oauth.ts` — `GET /api/oauth/:provider/authorize` →
provider page → `GET /api/oauth/:provider/callback` stores tokens in vault under
`${provider}_oauth_token` (keyed by **provider**, not connector id — see
`connectors.ts:16-23` `OAUTH_PROVIDER_FOR_CONNECTOR`). Only 5 providers configured
(github/slack/google/notion/jira, `oauth.ts:26-62`) and each needs app `client_id`/`client_secret`
pre-seeded in vault. This is the heavyweight path; the design's "token goes to your vault" implies
the **lightweight token-paste** path of §3.2, not OAuth.
### 3.4 Connector tools become live on the NEXT turn
Important for "the connected follow-up": connector action tools are **dynamic**.
`connectorRegistry.generateTools()` generates `connector_<id>_<action>` tools **only for connected
connectors** (`packages/server/src/local/index.ts:1005-1006` comment + `1034`). The agent loop
rebuilds `effectiveTools` per request (`chat.ts:1048-1073` → `buildToolsForWorkspace`). So once a
connector is connected mid-conversation, its tools appear on the **next** user turn (or next loop
iteration if connect happens inside the same turn before tool-pool rebuild — but the rebuild is
per-`/api/chat` call, so realistically next turn). `describeToolUse` already formats
`connector_<id>_<action>` as "<action> via <id>" (`chat-helpers.ts:192-196`).
### 3.5 MCP enable — yet another backend
MCP servers install through the MCP Hub (`MCPHubApp.tsx`, security scan + scope + approval). Not
chat-loop reachable today. Out of the *critical* path for Variation B's "Salesforce connector"
example, but the shared store must cover the "MCP Enable→Enabling…→Enabled" lane.
---
## 4. The DELTA — what's missing vs the Screen-09 contract
| # | Contract requirement | Current state | Gap |
|---|---|---|---|
| D1 | Agent can **offer a missing connector mid-conversation** | Agent can only `find_connector` (returns catalog text); no tool connects one | **No `connect_capability`/`offer_connector` tool** that raises an approval whose approve-side writes a token to vault. Needs a new agent tool OR a server-side "offer" step. |
| D2 | Inline card is **vault-aware** ("token goes to your vault") | `ApprovalGate`/`InlineApprovalCard` are generic; show `toolName input` + risk | No connector-typed variant: no token-input field, no "encrypted in your local vault" copy, no Connect→Signing in…→Connected micro-states. |
| D3 | **Token→vault** on approve (~1.1s) | `POST /api/connectors/:id/connect` writes vault, but is reached only from MarketplaceApp/ConnectorsApp FE | The chat approve path resolves a `boolean` promise (`approval.ts:31`); it has **no channel to carry a token** nor to invoke `connectConnector`. The approval contract is boolean-only. |
| D4 | **Connected follow-up** shown in thread | Connector tools regenerate per request; agent can use them next turn | No explicit "connected" confirmation block; the follow-up is implicit. Needs a success block/toast + (optionally) auto-continue of the original ask. |
| D5 | **Shared install store ("sync")** — one store, all 3 views reflect | `MarketplaceApp` uses **local `useState`** (`installing`/`extensions`, `MarketplaceApp.tsx:108`, `:231`); `ConnectorsApp`/`MCPHubApp` each own their own state | **No shared store.** Installing in chat would not reflect in the grid or count bar. This is the central PR4 artifact ("new install store"). |
| D6 | Type-aware **micro-states** + toast + count-bar update | Skill install returns text; connector connect returns `{connected:true}`; toasts exist per-app (`useToast`) | No unified progress→done state machine keyed by kind; no count bar; no cross-view toast. |
| D7 | Approval can carry **kind** (skill/connector/MCP) | `approval_required` carries `toolName` + trust meta, but kind is inferred from toolName | A connector offer needs an explicit `kind: 'connector'` + connector `id`/`name`/`why` so the card renders type-aware. |
---
## 5. Exact integration points a PR4 build would touch
### Server (agent loop + routes)
- **New agent tool** (e.g. `offer_connector` / `connect_capability`) in a new file under
`packages/agent/src/` (sibling to `connector-search.ts`), registered into `baseTools`
(`packages/server/src/local/index.ts:759`). It should be **gated** (add to `ALWAYS_CONFIRM`,
`packages/agent/src/confirmation.ts:16-26`, or rely on `connector_` prefix patterns) so it
hits the chat approval hook.
- **`chat.ts:909-1045` pre:tool hook** — extend the trust-metadata branch (currently special-cases
`install_capability` at `chat.ts:960`) to emit a **connector-typed** `approval_required` payload
(`kind: 'connector'`, connector `id`/`name`, "why", and a flag that a token field is needed).
- **`describeToolUse`** (`chat-helpers.ts:106-201`) — add a case for the new tool so the card's
description line is specific ("Connect Salesforce — token stored in your local vault").
- **Approval contract widening** — `POST /api/approval/:requestId` (`approval.ts:10`) +
`pendingApprovals` resolve currently carry only `boolean`. To pass a pasted **token** from the
inline card to the connect step, EITHER:
(a) the card calls `adapter.connectConnector(id, { token })` directly (writing vault via
`connectors.ts:96`) and *then* approves the tool with a boolean (token never transits the
approval channel — cleanest, reuses existing vault route), OR
(b) widen the approval body to carry the token and have the new tool's `execute` call
`setConnectorCredential`. Option (a) is the lower-risk path and keeps the vault write on the
audited `/connect` route.
- **Vault write** stays `fastify.vault.setConnectorCredential(id, …)` (`connectors.ts:122`) — do
not build a parallel secret store (CLAUDE.md §7.1).
### FE (chat + store)
- **`useChat.ts`** — the `approval_required` case (`useChat.ts:258-263`) sets a singleton
`pendingApproval`. For Variation B the connector offer can stay on this channel (it IS an
approval), but the payload must carry `kind`/connector fields so the renderer can branch.
Alternatively introduce an **install/offer block type** in `ContentBlock` (`types.ts:484`) +
`BlockRenderer` (`BlockRenderer.tsx`) so the offer lives *in* the thread like `ArtifactBlock`,
surviving history reload — recommended for the "connected follow-up" persistence.
- **New `ConnectorOfferCard`** (or extend `InlineApprovalCard`, `warm/InlineApprovalCard.tsx`) —
type-aware card with a token field, "encrypted in your local vault" copy, and
Connect→Signing in…→Connected micro-states. Render it from `ChatApp` where `ApprovalGate` renders
today (`ChatApp.tsx:1213-1215`), branching on `pendingApproval.kind`.
- **Shared install store** (the PR4 centerpiece, `BUILD-PLAN.md:142` "new install store") — a
React context/zustand store keyed by capability id with `{ kind, state: idle|installing|done }`,
consumed by `MarketplaceApp` (replacing its local `useState` at `MarketplaceApp.tsx:108/231`),
`ConnectorsApp`, `MCPHubApp`, AND the chat offer card. The card's approve handler calls
`adapter.connectConnector` and updates the store → grid + count bar reflect instantly.
- **Adapter** — reuse `adapter.connectConnector(id, { token })` (`adapter.ts:1971`),
`adapter.respondApproval(requestId, true)` (`adapter.ts:1757`), `adapter.installMarketplacePackage`
(skills/packages), and the connector list `adapter.getConnectors()` (for the count bar).
### Recommended seam (lowest-risk wiring)
1. New gated agent tool `offer_connector(id, why)` → emits connector-typed `approval_required`
(no token in the tool args; the token is collected by the FE card).
2. FE renders `ConnectorOfferCard`; on Connect it (a) `adapter.connectConnector(id, { token })`
→ vault write on the audited route, (b) updates the shared install store (grid/count-bar sync),
(c) `adapter.respondApproval(requestId, true)` to release the agent.
3. The tool's `execute` returns "Connected — Salesforce tools now available"; the agent uses
`connector_<id>_<action>` tools on the **next** turn (already live after `generateTools()`).
4. A success block/toast renders the "connected follow-up".
---
## 6. Risks / sharp edges
- **Boolean-only approval channel.** The existing approval handshake resolves a `boolean`
(`approval.ts:31`). Threading a secret token through it would put a credential on the approval
wire — prefer the FE-calls-`/connect`-directly seam (Option 5a) so the token stays on the
dedicated vault route.
- **Two parallel inline-approval components** (`ApprovalGate` in `ChatApp.tsx:225` vs warm
`InlineApprovalCard`). Only `ApprovalGate` is wired. Building a third card risks a 3-way drift;
consolidate onto the warm primitive.
- **Approval is a singleton, out-of-band of blocks** (`useChat.ts:262` returns without pushing a
block). It does **not** survive history reload and there is only one at a time. If the offer must
persist in the transcript / show a permanent "connected" follow-up, it needs to become a real
`ContentBlock` (new type), which touches the block union, `useChat`, `BlockRenderer`, and history
serialization.
- **Connector tools are next-turn, not same-turn.** The "connected follow-up that actually uses the
connector" won't have the `connector_<id>_*` tool in-loop on the same turn the connect happened
(tool pool is built once per `/api/chat`, `chat.ts:1048`). A same-turn auto-continue would need an
explicit re-dispatch.
- **No shared store today** means a chat-side install silently diverges from the grid/count bar —
the single most load-bearing PR4 requirement ("installing in any view reflects in all"). The store
must land before any of the three surfaces is "done".
- **MCP + skill lanes differ from connector.** Skill install is agent-reachable + vault-free;
connector connect is vault-bound + HTTP-only; MCP is Hub-only. A unified "type-aware one-click"
card must dispatch to three different backends behind one store interface.
- **OAuth vs token-paste.** Some connectors (the Google family, jira, slack, github) are OAuth, not
token-paste (`oauth.ts:26-62`). The vault-aware token field only fits `bearer`/`apiKey` connectors;
OAuth connectors need the redirect flow, which cannot complete inside an inline card without a
popup/redirect. The card must branch on `connector.authType`.
---
## 7. Open questions for the founder/lead
1. **Approval channel for tokens** — keep the approval handshake boolean and have the FE call
`/connect` directly (audited vault route), or widen the approval body to carry the token?
(Recommend the former.)
2. **Offer as block vs singleton** — should the inline connector offer (and its "connected"
follow-up) be a persistent `ContentBlock` in the transcript, or stay the ephemeral singleton
approval? (Persistence implies a new block type + history serialization.)
3. **One inline card or two** — consolidate `ApprovalGate` and `InlineApprovalCard` into the
warm primitive before adding a connector variant?
4. **OAuth connectors** — for OAuth-only connectors (Google/Slack/etc.), does Variation B fall
back to "open the Connector Hub" or attempt an in-chat popup redirect? Token-paste only covers
`bearer`/`apiKey` connectors.
5. **Same-turn vs next-turn follow-up** — is "shows the connected follow-up" satisfied by a
success toast + the tool being available next turn, or must the agent auto-continue and use the
connector in the same turn (requires re-dispatch)?
6. **Shared store shape** — new dedicated store, or extend an existing provider? It must be the
single source the grid count bar, the agent-pick suggestion box, and this card all read/write.
7. **What raises the offer** — a new gated agent tool the model calls when it hits a capability
gap, or a server-side heuristic (e.g. tool-not-found → CapabilityRouter, `chat.ts:1080`) that
injects the offer? The `CapabilityRouter` already exists for tool-not-found handling and could
be the trigger.
---
## 8. Key files (quick index)
| Path | Role |
|---|---|
| `docs/design_handoff_waggle_app/SCREENS.md:189-207` | Screen-09 contract (Variation A/B + sync) |
| `packages/server/src/local/routes/chat.ts:909-1045` | pre:tool approval hook — emits `approval_required`, blocks on `pendingApprovals` |
| `packages/server/src/local/routes/chat.ts:475-477,1005-1009` | `sendEvent` + the approval payload shape |
| `packages/server/src/local/routes/approval.ts:10-35` | `POST /api/approval/:requestId` → resolves the boolean promise |
| `packages/server/src/local/routes/connectors.ts:96-156` | `POST /connect` — **token→vault** (`setConnectorCredential`) |
| `packages/server/src/local/routes/oauth.ts` | OAuth redirect flow (heavyweight, 5 providers) |
| `packages/agent/src/connector-search.ts:161-265` | `find_connector` / `list_connector_categories` (discovery only — no connect) |
| `packages/agent/src/skill-tools.ts:480-668` | `install_capability` — the only agent-loop install (skills, vault-free) |
| `packages/agent/src/confirmation.ts:16-26` | `ALWAYS_CONFIRM` gate set + `needsConfirmation` |
| `packages/server/src/local/index.ts:759,1005-1034` | tool-pool assembly; connector tools generated for **connected** connectors only |
| `packages/server/src/local/routes/chat-helpers.ts:106-201` | `describeToolUse` (approval description line) |
| `apps/web/src/hooks/useChat.ts:258-333` | FE: `approval_required` → singleton `pendingApproval`; `approveAction` |
| `apps/web/src/components/os/apps/ChatApp.tsx:225-311,1213-1215` | `ApprovalGate` inline card + its render slot |
| `apps/web/src/components/os/warm/InlineApprovalCard.tsx` | reusable warm inline-approval primitive ("Approve before I leave your machine") |
| `apps/web/src/components/os/apps/chat-blocks/ArtifactBlock.tsx` | precedent: tool-result → rich actionable in-thread card |
| `apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx` | block dispatch (where a new offer block would route) |
| `apps/web/src/lib/types.ts:410-430,484-532` | `ApprovalRequest` + `ContentBlock` union |
| `apps/web/src/lib/adapter.ts:1757,1971` | `respondApproval`, `connectConnector` |
| `apps/web/src/components/os/apps/MarketplaceApp.tsx:108,221-231` | grid install — **local `useState`** (no shared store) |

View File

@@ -0,0 +1,234 @@
# PR4 Recon — Slice 5: Agent-Search / Agent-Pick
**Screen 09 Marketplace · Variation A "agent-suggestion box"**
"Describe what you want to do…" → agent recommends a **connector + skill + tool**, each with a **"why"** reason and an **install** button.
Branch: `feature/warm-hive-pr4` · READ-ONLY recon · cited file:line as of 2026-06-16.
---
## TL;DR
The agent-pick engine **already exists and is good**: `searchCapabilities()` in
`packages/agent/src/capability-acquisition.ts:179` takes a natural-language `need` and returns a
ranked, deduped `AcquisitionProposal` of native tools / installed skills / starter-pack skills /
marketplace packages — **each candidate already carries a `matchReason` ("why") and an
`installAction`** plus a trust assessment. It fuses **keyword scoring** (no embeddings) across local
sources with **pre-fetched marketplace FTS5 candidates**.
**The gap is entirely the surface, not the brain.** This engine is reachable **only as an agent tool**
(`acquire_capability`, `packages/agent/src/skill-tools.ts:406`) invoked inside the chat loop — there is
**no HTTP route** that the Screen-09 agent-search bar could `POST` a need to and render the
three-up suggestion box. PR4 must add a thin REST endpoint over the existing `searchCapabilities()`
(plus its deps assembly that today lives inline in `local/index.ts:620`) and a new
`MarketplaceApp` agent-search UI. The matching scoring is single-recommendation today
(`proposal.recommendation` is ONE candidate); the design wants **one-of-each-kind** (connector+skill+tool),
which is a selection/grouping change on top of the existing ranked `candidates[]`, not new matching.
---
## What exists today (verified)
### 1. The matching brain — `searchCapabilities()` (the agent-pick core)
`packages/agent/src/capability-acquisition.ts`
- **Input** (`SearchCapabilitiesInput`, :170): `{ need, installedSkills[], starterSkillsDir,
nativeToolNames[], marketplaceCandidates[] }`. Marketplace candidates are **pre-fetched and passed
in** — `searchCapabilities` itself does no IO except reading starter-skill `.md` files from disk
(`loadStarterSkillsMeta`, :142).
- **Algorithm** (no embeddings — pure keyword): `extractKeywords()` (:64, stop-word filtered) →
`scoreMatch()` (:74, name-hit ×2 / content-hit ×1, normalized 01) across four source lanes:
1. native tools (scored vs `NATIVE_TOOL_HINTS` map, :113);
2. installed/active skills (:218);
3. starter-pack skills not yet installed (:241);
4. marketplace candidates, merging the FTS score when present via `Math.max(keywordScore, mkt.score)` (:270).
- **Output** (`AcquisitionProposal`, :39): `{ need, gapDetected, summary, candidates[]≤8,
recommendation, alreadyHandled }`. Each `CapabilityCandidate` (:27) already has the exact fields
Screen-09 needs: `name`, `type` ('native'|'skill'|'plugin'|'mcp'|'connector'|'marketplace'),
`availability`, `description`, `matchReason` (**the "why"**, built by `buildMatchReason` :99),
`installAction` (string|null), and `trust`.
- **Recommendation is SINGLE** (:307314): picks the best installable (or best active if
already-handled). It does **not** group into connector+skill+tool. The full ranked `candidates[]`
is there to do that, but the grouping logic does not exist yet.
- **`summary`** is a markdown string built for the **chat** surface (`buildProposalSummary`, :330): it
even **emits the inline-install marker** (`<!--waggle:capability_request {...}-->`, :385) verbatim for
the chat card. This is debug/chat-grade prose — a UI agent-suggestion box would consume the
**structured `candidates`/`recommendation`, not `summary`**.
### 2. How it's invoked today — the agent tool `acquire_capability`
`packages/agent/src/skill-tools.ts:404478`
- Tool `acquire_capability` (param: `need`) gathers deps: `getInstalledSkills()`, `starterSkillsDir`,
`nativeToolNames`, and calls `deps.searchMarketplace(need)` (graceful try/catch) to pre-fetch
marketplace candidates, then calls `searchCapabilities(...)` and **returns `proposal.summary`** (the
markdown string) to the model. Audit event recorded on gap (:461).
- Companion tool `install_capability` (:482) installs **starter-pack skills only** (validated by
`validateInstallCandidate`, capability-acquisition.ts:424 — rejects any source ≠ `starter-pack`).
- The deps are wired in `packages/server/src/local/index.ts:620` (`createSkillTools({...})`):
- `nativeToolNames` = union of mind/system/plan/git/document tool names (:624);
- `getInstalledSkills` = live `server.agentState.skills` (:631, hot-reloadable);
- `searchMarketplace` = `marketplaceDb.search({ query, limit: 10 })` mapped to `MarketplaceCandidate[]`
(:641656) — note `score` is hardcoded `undefined` (FTS rank not surfaced through the API).
### 3. The marketplace search it sits on
`packages/server/src/local/routes/marketplace.ts:57` — `GET /api/marketplace/search`
→ `MarketplaceDB.search()` (`packages/marketplace/src/db.ts:86`).
- FTS5 over the `packages` table. **Critically, `db.search` already tolerates a verbose NL `need`**:
`toFtsMatchQuery()` (db.ts:9199) relaxes the raw string into an OR-of-prefixes and falls back to an
unfiltered listing rather than throwing — so the agent's natural-language need works as-is.
- `SearchResult` (`types.ts:233`): `{ packages[], total, facets{types,categories,sources},
installedCount }`. Each `MarketplacePackage` carries `waggle_install_type` ('skill'|'connector'|'mcp')
→ **the kind badge**, `package_type`, `description`, `downloads` (install count), and (route-annotated
at marketplace.ts:105) `installed`, `scanStatus`. **`installedCount`** is the natural source for the
"N in this workspace" count bar (currently catalog-wide, not workspace-scoped — see Gaps).
### 4. The parallel, narrower "recommend" path (skills-only) — DO NOT confuse with agent-pick
`packages/agent/src/skill-recommender.ts` (`SkillRecommender.recommend(context, topN)`, class at :118).
- Multi-signal keyword + bigram + synonym-cluster matcher over **installed skills only** (no
marketplace, no connectors, no MCP). Returns `SkillRecommendation[] = {skillName, reason,
relevanceScore}` — also a "why" (`reason`), but skills-only.
- Exposed over HTTP at `GET /api/skills/suggestions?context=&topN=`
(`packages/server/src/local/routes/skills.ts:393406`). This is the **only** existing HTTP surface
that returns "what should I use" with a reason — but it's the wrong shelf (skills only, already
installed) for Screen-09's connector+skill+tool suggestion box. Useful as a *prior-art pattern* for
shaping the new route.
### 5. The router (a third matcher) — for completeness
`packages/agent/src/capability-router.ts` (`CapabilityRouter.resolve(query)`, :58). Maps a query to
ranked routes across native/connector/skill/plugin/mcp/subagent with confidences. **Resolution, not
recommendation** — returns "where could this be handled" not "install this". Connector lane (:85)
knows `connected` status and emits a suggestion when not connected. Not currently HTTP-exposed; a
secondary input if PR4 wants live-connector awareness in the suggestion box.
### 6. The inline-card render path (the "sync" downstream, shared with the grid)
- Parser: `apps/web/src/components/os/apps/chat-blocks/capability-request-parser.ts` — `segmentText()`
splits agent text on the `<!--waggle:capability_request {name,source,reason}-->` marker (and a legacy
phrasing) into install-card segments, deduped by `source::name`.
- Card: `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` — renders the
**pending→installing→installed/failed** micro-states (:17,:107149) with a "why" line from
`request.reason` (:103). Install routing already branches by source: marketplace →
`adapter.searchMarketplace` then `adapter.installMarketplacePackage(pkg.id)` (:4854, tier-gated, 403
→ UpgradeModal); starter-pack → `adapter.installPack` (:69). **This is exactly the type-aware
one-click flow §09 asks for, already built for the inline-chat variation (Variation B).**
### 7. Adapter methods already present (the install actions)
`apps/web/src/lib/adapter.ts`: `searchMarketplace(query,limit)` (:1334), `installMarketplacePackage(id)`
(:1338), `installPack(skillId)` (:1292), `connectConnector(id,creds)` (:1971), `installMcp(mcpId)`
(:2017). All three install kinds in the §09 sync spec (skill Add / connector Connect / MCP Enable)
have adapter coverage.
---
## Gaps vs the Screen-09 contract
1. **No HTTP route for agent-pick.** `searchCapabilities()` is reachable ONLY inside the chat agent
loop via the `acquire_capability` tool. The Screen-09 centered agent-search bar ("Ask the agent")
needs a `POST /api/marketplace/agent-search` (or similar) that runs `searchCapabilities` and returns
**structured candidates** (not the chat `summary` markdown). Must be built on top of the existing
engine.
2. **Returns chat-grade `summary`, not a structured suggestion box.** The tool returns
`proposal.summary` (markdown for the model). The UI needs the raw `candidates[]`/`recommendation`
JSON. The data is computed (proposal object) but **discarded** at skill-tools.ts:476 — a route would
return the object directly.
3. **Single recommendation, not one-of-each-kind.** `proposal.recommendation` is ONE candidate
(capability-acquisition.ts:307). §09 wants **connector + skill + tool** (three, one per kind) each
with its own why + install. Needs a small grouping pass over the ranked `candidates[]` (top per
`type`/`availability`) — new logic, but trivial given candidates already carry `type`.
4. **Marketplace deps assembly lives inline in `local/index.ts`, not reusable.** The
`getInstalledSkills` / `nativeToolNames` / `searchMarketplace` closure that feeds the tool is
constructed once at server boot (index.ts:620). A new route needs the same deps — either lift this
into a shared helper or have the route reconstruct it. (Native tool names, in particular, are only
assembled in that closure.)
5. **"N in this workspace" count bar is catalog-wide, not workspace-scoped.** `installedCount`
(db.search → types.ts:242) counts all installed packages globally; the marketplace DB is not
workspace-partitioned. §09's per-workspace chip count has **no backing field** today.
6. **No "example chips" / suggested-need seeding.** Pure UI; no backend. Can be static or derived
from persona `suggestedCommands` — out of agent-pick scope.
7. **No shared install-state store on the frontend.** §09's CRITICAL "sync" (one store powers grid +
agent-pick + inline card, installing in any view reflects in all) — the current `MarketplaceApp.tsx`
(no agent-search at all, install state local to each card) and `CapabilityRequestCard` (local
`useState` phase) have **independent** state. This is the headline PR4 frontend build; agent-pick is
one of the three consumers of that store. (Owned by the "shared install store" slice — flagged here
as the integration boundary.)
8. **`MarketplaceApp.tsx` has no agent-search UI.** Verified: the 417-line component
(`apps/web/src/components/os/apps/MarketplaceApp.tsx`) contains no "Ask the agent" / suggestion /
`acquire`-style references — only grid install/uninstall via `installMarketplacePackage` (:228).
Variation A's centered bar + suggestion box must be built net-new.
9. **Marketplace FTS rank not surfaced.** `searchMarketplace` hardcodes `score: undefined`
(index.ts:651), so marketplace candidates rank purely by keyword re-scoring inside
`searchCapabilities`. Acceptable, but means FTS relevance is currently dropped on the floor for the
agent-pick path.
---
## Exact integration points a PR4 build would touch
- **Reuse (engine):** `searchCapabilities(input): AcquisitionProposal`
— `packages/agent/src/capability-acquisition.ts:179`. Exported from `@waggle/agent`
(`packages/agent/src/index.ts:326`). Types `CapabilityCandidate` / `AcquisitionProposal` /
`MarketplaceCandidate` / `SearchCapabilitiesInput` are all exported.
- **Build (route):** new `POST /api/marketplace/agent-search` (body `{ need }`) in the marketplace
route file `packages/server/src/local/routes/marketplace.ts` — runs `searchCapabilities` with deps
assembled like `local/index.ts:620657`, returns structured `{ candidates, recommendation,
groupedByKind }`. Pattern to mirror for shape/contract: `GET /api/skills/suggestions`
(`packages/server/src/local/routes/skills.ts:393`).
- **Reuse (deps):** marketplace search `MarketplaceDB.search({query,limit})`
(`packages/marketplace/src/db.ts:86`); installed-skills source `server.agentState.skills`; native
tool-name union (currently only assembled at `local/index.ts:624` — lift if reused).
- **Add (grouping):** a `pickOnePerKind(candidates)` helper (new) to satisfy connector+skill+tool —
trivial reduce over `candidate.type`.
- **Install actions (already present, reuse):** `adapter.installMarketplacePackage` /
`adapter.installPack` / `adapter.connectConnector` / `adapter.installMcp`
(`apps/web/src/lib/adapter.ts:1338/1292/1971/2017`). Marker/card render reuse:
`segmentText` + `CapabilityRequestCard` (chat-blocks/).
- **Count bar:** `SearchResult.installedCount` (`packages/marketplace/src/types.ts:242`) via
`/api/marketplace/search`; needs workspace-scoping if §09's per-workspace count is taken literally.
- **Audit (existing, ride along):** `fastify.auditStore?.record(...)` already called on gap in
skill-tools.ts:461 — a route should record proposals the same way.
---
## Risks / watch-outs
- **Two parallel matchers + a router** (`searchCapabilities` vs `SkillRecommender` vs
`CapabilityRouter`) — building a new route on the wrong one (e.g. `SkillRecommender`, which is
skills-only and HTTP-exposed already) would silently drop connectors/MCP. **Use
`searchCapabilities`.**
- **`summary` vs structured-candidates confusion** — the tool's return value is markdown; do not parse
it for the UI. Return the proposal object from the new route.
- **Keyword-only matching** — no embeddings; verbose/synonym-heavy needs may under-match. Acceptable
for v1 but the suggestion box may look thin on phrasing mismatch. (`SkillRecommender` has synonym
expansion; `searchCapabilities` does not.)
- **Tier gating asymmetry** — marketplace/MCP install is PRO-gated (marketplace.ts:181 `requireTier`),
starter-pack/skill is free. The suggestion box must reflect this (the inline card already 403→Upgrade,
CapabilityRequestCard.tsx:55).
- **Workspace scoping of installs** — marketplace DB is global; the "N in this workspace" framing may
over-promise isolation that the substrate doesn't provide.
- **Native tool-name list is closure-local** (index.ts:624) — a route reconstructing deps must not
drift from the real registered tool set, or agent-pick "already have a tool" answers go stale.
---
## Open questions for the founder/lead
1. **Route shape:** dedicated `POST /api/marketplace/agent-search`, or extend
`GET /api/marketplace/search` with an `agentPick=true` mode? (The former is cleaner given the verbose
NL body + structured proposal response.)
2. **One-of-each-kind vs top-N:** §09 shows exactly connector+skill+tool (3). When a kind has no match
(e.g. no relevant connector), show 2? Show an empty-kind hint? Define the grouping contract.
3. **Count bar semantics:** is "N in this workspace" literally per-workspace (needs new
workspace-scoped install tracking) or is catalog-wide `installedCount` acceptable for v1?
4. **Suggestion-box "why":** use the engine's `matchReason` (keyword-hit-grade, e.g. "name matches:
risk") as-is, or have the route pass candidates to the LLM for a one-line natural "why"? The former
is free + deterministic; the latter is prettier but adds a model call.
5. **Shared install store ownership:** confirm the frontend "sync" store is a separate PR4 slice that
agent-pick plugs into (this recon treats it as the integration boundary, not part of Slice 5).

View File

@@ -0,0 +1,40 @@
# PR4 Grounding — recon verified against live code (2026-06-16)
5-reader workflow `wf_19393d8b-c2d` (~669k tokens). Verdicts: Slice1 minor-drift · Slice2 confirmed · Slice3 confirmed · Slice4 **MAJOR-drift** · Slice5 minor-drift. **Net: the build plan is sound; the recon DOCS had 2 material errors that change Phases C/D execution (not the architecture).**
## Material drifts (these change HOW a phase is built)
### D-A (Slice 4, MAJOR) — Phase D binds to `CapabilityRequestCard`, NOT the approval gate
Recon doc `04-inline-in-chat.md` pointed Phase D at `ApprovalGate`/`InlineApprovalCard` (the SSE `approval_required` singleton). **Wrong.** The live inline-install card is **`chat-blocks/CapabilityRequestCard.tsx`**, parsed from agent TEXT by `capability-request-parser.segmentText` (marker `<!--waggle:capability_request {json}-->` or legacy phrasing) and rendered by `TextBlock.tsx:25-27`. The build plan §2/§5 already names CapabilityRequestCard correctly — so the PLAN is right, only recon-04's framing was misleading. **Phase D = (1) widen `CapabilityRequest.kind` `'skill'|'marketplace'` → `+'connector'|'mcp'` (parser already passes `kind` through verbatim — parser.ts:22 — no parser change to ACCEPT, only to RENDER/DISPATCH); (2) add connector/mcp dispatch branches in `CapabilityRequestCard.handleInstall` routing through the shared store; (3) connector approve is FE-direct `adapter.connectConnector(id,{token})` — token NEVER transits the boolean approval wire (`PendingApproval.resolve(approved:boolean)` — index.ts:168). Do NOT build a 3rd ConnectorOfferCard.** Marker regex `(\{[^}]+\})` forbids nested `}` — fine, a connector offer is flat `{name,source,kind,reason}`.
### D-B (Slice 5, minor but load-bearing) — `searchCapabilities` has NO connector/mcp lane
`searchCapabilities()` only ever EMITS `type ∈ {'native','skill','marketplace'}` — never `'connector'|'mcp'|'plugin'` (truth table below). ALL marketplace results collapse to `type:'marketplace'` because the `searchMarketplace` closure (index.ts:646) drops `waggle_install_type`. Connectors aren't even an input (they live in `connectorRegistry`, not marketplace.db). **So §09's literal "connector + skill + tool" three-up box CANNOT be produced by `pickOnePerKind(candidate.type)`.****PHASE-C DECISION (founder-gate when I reach C):** either (a) **honest scope** — group on what the engine really emits: *native-tool + skill + marketplace-pkg*, each with real `matchReason` "why" + `installAction` (delivers a true three-up box, no connector lane); or (b) **add a connector lane** — keyword-match `connectorRegistry.getDefinitions()` in the route and inject connector candidates + carry `waggle_install_type` through `MarketplaceCandidate` so mcp/skill packages bucket correctly (scope+). Recommend (a) for v1; flag (b) as the "literal §09" upgrade.
## Verified contracts (verbatim — bind to these)
### Phase A — shared install store (UNAFFECTED by drifts; fully grounded, build now)
- **No FE store exists** (grep InstallProvider/useInstallStore/InstallStore → none). Providers pattern = React context (`ServiceProvider`/`ShellContext`/`ThemeProvider`), no Redux/Zustand. `useService()` exposes `{connecting}`; gate authed hydrate on connect-settled (MarketplaceApp.tsx:101).
- **Value type + normalizers REUSE** `apps/web/src/lib/extension-catalog.ts`: `Extension{id,name,description,type,source,installed,lifecycle:'available'|'installed',installable,kind:'package'|'pack'|'federated',packageId?,scanStatus?,trust?,category?,openIn?}`. ID scheme keys the store: `pkg:<n>|pack:<slug|id|name>|connector:<id>|agent:<id>|model:<id>|template:<id>|mcp:<id>`. `installable` is hard-coded FALSE today for connector/mcp/pack → Phase B flips connector/mcp to true once the store can install them.
- **Install verbs (adapter, REUSE):** skill `installSkill(id, source:'starter'|'pack'|'marketplace', packageId?)` | starter `installPack(skillId):void` | marketplace pkg `installMarketplacePackage(packageId:number):Promise<Response>` (RAW — inspect .status/.json; 403 TIER fires `waggle:tier-insufficient`, 403 `{blocked}`, 422 fail) | connector `connectConnector(id,{token?|apiKey?...}):void` (UNGATED) | mcp `installMcp(mcpId,opts?):{installed,server?,status?,requiresApproval?}` (PRO).
- **Hydrate reads:** `GET /api/skills` {skills:[{name,...}],count} · `GET /api/connectors` {connectors:ConnectorDefinition[]} (status derived live: connected/disconnected/expired/error) · `GET /api/mcps` {mcps:McpListItem[],total,installed} (per-row installed/status/scope) · `GET /api/marketplace/installed` {installations,total} + `/search` per-row `installed` flag (the ONLY queryable installed-set; skills/connectors/mcp derive at read).
- **Reconcile hazard:** install can 403(TIER)/403(SecurityGate)/422 AFTER an optimistic flip → store rolls back + routes to upgrade(`waggle:tier-insufficient`)/approval; count bar must never show a gate-rejected item.
- **Count bar (D1 ratified):** FE-derived GLOBAL count (install_audit has NO workspace_id, is append-only + OSS-excluded; `getInstalledCount()` is global). Label honestly "installed", not "in this workspace".
### Phase B — Variation A grid
- `ExtensionCard` props `{ext, installing?, onInstall?(ext), onUninstall?(ext), onOpenIn?(appId)}`; testids root `extension-card`, install `extension-install-${ext.id}`. Today: generic Install/Remove/Open-in, no type-aware verbs, no micro-states. Phase B adds Add/Connect/Enable + idle→in-progress→done from the store.
- `MarketplaceApp` exports (named) `installRiskFor`/`buildRemoveRequest`/`buildInstallRequest` + default. Facets today = `['all',...EXTENSION_TYPES]` = 7. D2 collapse → 4 (All/Skills/Connectors/MCP). Count today = `visible.length` → replace with store count bar.
- **Tests to rewrite = 10 `it()` blocks** (recon said 11 — DRIFT) in `apps/web/src/test/phase4b-marketplace-extend.test.tsx`; adapter mocked via `vi.hoisted`. Pinned testids: extension-facets, federated-note, extension-card, extension-install-pkg:7/:9, approval-modal(-approve); call shapes `getMarketplace({type:'skill',limit:30})`/`({type:'mcp',limit:30})`.
- **Path correction:** ExtensionCard lives at `components/os/apps/extend/ExtensionCard.tsx` (nested under apps/), NOT `os/extend/`.
### Phase C — POST /api/marketplace/agent-search (route under `packages/server/src/local/routes/`, NOT `src/routes/`)
- Engine `searchCapabilities(input:SearchCapabilitiesInput):AcquisitionProposal` exported from `@waggle/agent` (index.ts:326). Input `{need, installedSkills:[{name,content}], starterSkillsDir, nativeToolNames?, marketplaceCandidates?}` — engine does NO marketplace IO (candidates passed in).
- `CapabilityCandidate{name,type,availability,description,source,matchScore,matchReason,installAction,trust?}`. **Emitted-type truth table:** native→`type:'native',availability:'active',installAction:null`; active skill→`'skill','active',null`; starter→`'skill','installable','install_capability'`; marketplace→`'marketplace','installable','install_capability'`. `recommendation` = SINGLE candidate; `candidates` capped at 8; `summary` is markdown for the model — **return STRUCTURED candidates/recommendation, NOT summary** (skill-tools.ts:476 discards it).
- **Deps to lift (index.ts:620-657):** `nativeToolNames` = union of mind/system/plan/git/document tool names (closure-local; OMITS search/browser/cli/cron/connector — so web_search can't score even today); `getInstalledSkills` = `server.agentState.skills ?? loadSkills`; `searchMarketplace` = `marketplaceDb.search({query,limit:10}).packages → MarketplaceCandidate` (drops waggle_install_type + score). Ride-along audit `fastify.auditStore?.record({...action:'proposed',initiator:'agent'})`.
### Phase D — inline (see D-A above for the corrected target)
- Boolean approval channel CONFIRMED: `PendingApproval.resolve:(approved:boolean)` (index.ts:168); `POST /api/approval/:requestId` body `{approved,always?,reason?,sourceWorkspaceId?}` — NO token. `adapter.respondApproval(requestId,approved,opts)`.
- Connect route `POST /api/connectors/:id/connect` body `{token?|apiKey?,refreshToken?,expiresAt?,scopes?,email?}` → vault `setConnectorCredential`; 404/400/503; authType = `registry.get(id)?.authType ?? 'bearer'`. **Card must branch on authType** — token-paste fits bearer/api_key only; OAuth (oauth2) needs redirect → Hub fallback (D3). Google family connector-id ≠ provider key (`OAUTH_PROVIDER_FOR_CONNECTOR`).
- Gate a new agent offer/connect tool: add exact name to `ALWAYS_CONFIRM` (confirmation.ts:16) OR name it `connector_<id>_<writeverb>_*` (CONNECTOR_WRITE_PATTERNS).
## install_audit (do not touch as state)
Append-only INSERT-only, NO `workspace_id`, action vocab = `proposed|approved|installed|rejected|failed|blocked|uninstalled` (NO 'synced'/'connected' — adding one is a CHECK migration). OSS-EXCLUDED (interleaved in hive-mind-core schema.ts/db.ts; curated out by hand) → install_audit-only changes have nowhere to land on the mirror. NEVER the source of installed state.

View File

@@ -0,0 +1,116 @@
# PR7 Recon · 01 — Stripe / Billing BACKEND state
**Scope:** What is REAL vs MUST-BUILD for the 4 billing screen states (Plans / Checkout / Success / Manage) of **screen 14 (Billing)** — plus the auth-identity context that screen 13 (Auth) and "Manage" lean on.
**Method:** read + grep only, no product code changed. Every claim cites `file:line`.
**Target surface:** PR7 builds in **`apps/web`** (the React desktop cockpit) → talks to the **local Fastify sidecar** (`packages/server/src/local/index.ts`). This is a DIFFERENT server entry from the cloud/`apps/www` stack, and that distinction is the single most important fact below.
---
## 0. The two-stack split (read this first)
There are **two** server entry points and **two** Stripe/auth integrations. They must not be conflated:
| | **Local sidecar** (PR7 target) | **Cloud server / `apps/www`** (PR8 / not PR7) |
|---|---|---|
| Entry | `packages/server/src/local/index.ts` | cloud: `packages/server/src/index.ts`; landing: `apps/www/app/**` |
| Tier source | `config.json` on disk (`assert-tier.ts:21-32`) | Clerk `publicMetadata` + cloud DB |
| Auth | **none** — no Clerk plugin registered in `local/` (grep: 0 hits for `plugins/auth` in `src/local`) | Clerk: `packages/server/src/plugins/auth.ts` (only imported by `src/index.ts`); `apps/www` uses `@clerk/nextjs` |
| Stripe routes | `packages/server/src/stripe/**` (checkout/portal/sync/webhook) | `apps/www/app/api/stripe/checkout/route.ts` + `app/api/webhooks/stripe/route.ts` |
| Stripe ↔ identity link | customer id stored in **`config.json` `stripe_customer_id`** (`webhook.ts:60`, `portal.ts:28`) | customer id stored in **Clerk `publicMetadata.stripeCustomerId`** (`apps/www/.../checkout/route.ts:94-97`) |
`apps/web` consumes the **local sidecar** (adapter base = local URL; `apps/web/src/lib/adapter.ts` calls `/api/stripe/*`). So **PR7's backend is the `packages/server/src/stripe/**` set, NOT the richer `apps/www` Clerk-linked flow.** The `apps/www` flow (full Clerk identity + lazy Stripe Customer + lookup_key price resolution) is PR8 territory and should not be assumed available in the desktop cockpit.
---
## 1. Stripe backend inventory (local sidecar — the PR7 surface)
All routes registered via `stripeRoutes` at `packages/server/src/local/index.ts:128,2161`. All gate on `STRIPE_SECRET_KEY`; absent → **503 `STRIPE_NOT_CONFIGURED`** (`index.ts:25-43`, each route).
| Route | File:line | What it does | REAL? |
|---|---|---|---|
| `POST /api/stripe/create-checkout-session` | `checkout.ts:16` | Hosted Stripe Checkout session (`mode:'subscription'`), PRO/TEAMS only, period-aware price, returns `{url}` | **REAL** |
| `POST /api/stripe/create-portal-session` | `portal.ts:15` | Stripe **Customer Portal** session, `requireTier('PRO')` gated, needs `config.json.stripe_customer_id`, returns `{url}` | **REAL** |
| `POST /api/stripe/sync` | `sync.ts:21` | Poll-confirm after redirect (desktop behind NAT), retrieves session, **payment-gated** (`status==='complete' && paid`, `sync.ts:46-49`), writes tier to config | **REAL** |
| `POST /api/stripe/webhook` | `webhook.ts:74` | Signature-verified lifecycle handler — **flips tier in config.json** | **REAL** |
| `GET /api/tier` | `settings.ts:322` | Authoritative tier read (+ `trialDaysRemaining`, `trialExpired`) | **REAL** |
| `PATCH /api/tier` | `settings.ts:363` | Dev/testing tier override ("will be replaced by Stripe webhook") | REAL (dev) |
| `POST /api/tier/start-trial` | `settings.ts:401` | Atomic 15-day trial start | REAL |
| **invoice list** | — | **does not exist** (grep `invoice`/`invoices.list` in `packages/server` → 0 product hits; only a chat-keyword at `chat-helpers.ts:15`) | **MUST-BUILD** |
| **payment-method read/update** | — | **does not exist** (grep `paymentMethod`/`payment_method` → 0 hits) | **MUST-BUILD (or defer to Portal)** |
**Adapter methods already on the FE** (`apps/web/src/lib/adapter.ts`): `createCheckoutSession` (2667), `createPortalSession` (2675), `syncStripeCheckout` (2659), `getTier` (2682). The **`useBilling` hook already orchestrates the whole happy path** — `apps/web/src/hooks/useBilling.ts`: `startCheckout` opens the URL (69-81), `openPortal` opens the portal URL (84-96), `syncAfterCheckout` confirms (49-66), and it **auto-detects `?session_id=` on mount** to run sync (104-115). It also carries the honesty primitive `tierResolved` (16-21): until a real `getTier()` round-trip succeeds, the default `'FREE'` is a **placeholder, not a fact** — billing surfaces must render an unresolved state, not the FREE card.
### Webhook DOES flip tiers (REAL)
`webhook.ts:114-158` handles three events, all writing tier to `config.json` via `updateUserTier` (52-62):
- `checkout.session.completed` → grants tier **only if `payment_status` is `paid`/`no_payment_required`** (122) — unpaid sessions never grant.
- `customer.subscription.updated` → re-resolves tier from price id via `tierFromPriceId` (139).
- `customer.subscription.deleted` → **downgrades to FREE** (150).
Plus real hardening: raw-body signature verification (88-95), idempotency via `.stripe-processed-events.json` + a serialized critical section against TOCTOU double-processing (40-45, 102-165), atomic temp-file writes (25-29). CLAUDE.md §10 (E-10) confirms 17/17 webhook tests green.
---
## 2. Price wiring / env contract (REAL, documented)
`tierFromPriceId` (`index.ts:71-87`) and `priceIdForTier` (`index.ts:91-100`) resolve a **dual env contract**:
- **4-var** (matches `apps/www`): `STRIPE_PRICE_PRO_MONTHLY` / `_PRO_ANNUAL` / `_TEAMS_MONTHLY` / `_TEAMS_ANNUAL`.
- **legacy single-var** fallback: `STRIPE_PRICE_PRO` / `_TEAMS` / `_BASIC`(→PRO).
- final fallback: `TIER_CAPABILITIES[tier].stripePriceId` (`tiers.ts:122,143`, read from `STRIPE_PRICE_PRO`/`_TEAMS` env).
CLAUDE.md §10 (M7) records both **test (`acct_1SzHlbC0mmjh4oEM`) and live (`CNCrMQy1f7`)** Stripe accounts hold 2 products × 2 prices with lookup_keys `pro_monthly`/`pro_annual`/`teams_monthly`/`teams_annual`; live price IDs in `docs/launch/drafts/2026-05-12-apps-www-deployment-readiness.md`. **The price IDs/secret are EXTERNAL-DEP** (must be present in the sidecar's env at runtime). Pricing on the screen (Solo $0 / Pro $19 / Teams $49-seat, `SCREENS.md:294-295`) is **DERIVABLE** from `tiers.ts` doc-comment (`tiers.ts:7-12`) — but the literal dollar amounts are NOT machine-readable fields in `TIER_CAPABILITIES`; only `stripePriceId` is. So plan-card prices are static copy unless cross-checked against the Stripe dashboard.
---
## 3. Per-state verdict (screen 14 · Billing)
Screen spec: `SCREENS.md:282-295`. Note the explicit instruction: **"Use Stripe Checkout/Customer Portal where possible; theme to tokens."**
| State | Backend verdict | Evidence / what's needed |
|---|---|---|
| **Plans** (monthly/annual toggle, 3 cards) | **REAL (read) + DERIVABLE (copy)** | tier from `GET /api/tier` (`settings.ts:322`); current-plan highlight from `useBilling.tier` + `tierResolved`; prices are static copy derivable from `tiers.ts:7-12`. Upgrade buttons call existing `createCheckoutSession`. No new backend. |
| **Checkout** (custom card form: email, card 4242, expiry/CVC, country) | **MUST-NOT-BUILD as custom; redirect to HOSTED Checkout (REAL)** | The screen mock shows a **custom card form** (`SCREENS.md:287-289`), but the backend only produces a **hosted Stripe Checkout URL** (`checkout.ts:39-51`). There is **no card-tokenization / PaymentElement / Stripe.js** anywhere in `apps/web` (grep `CardElement`/`PaymentElement`/`4242` → 0 hits). **PCI/scope flag below.** Recommended: the "Checkout" segment is a themed **summary/preview that hands off to hosted Checkout**, not a real PAN field. |
| **Success** ("You're Pro", receipt, Manage) | **REAL** | Already wired: `useBilling` auto-runs `syncStripeCheckout` on `?session_id=` (`useBilling.ts:104-115`); sync is payment-gated (`sync.ts:46-49`). The post-redirect `/payment-success` URL is set at `checkout.ts:42`. "Receipt" line-items are **NOT returned by sync** (`sync.ts:83` returns only `{tier, customerId}`) → a real receipt would need a new fetch or Portal link → **MUST-BUILD or show generic confirmation**. |
| **Manage** (current plan, switch annual, payment method ···4242, next charge, **invoices PDF**, change/cancel) | **PARTLY REAL via Portal; the in-app detail is MUST-BUILD** | `createPortalSession` (`portal.ts:15`) gives a one-click jump to Stripe's **hosted Customer Portal**, which natively does payment-method update, invoice PDFs, plan change, cancel. **BUT** rendering those *inside* the cockpit (the "VISA ···4242", "next charge", invoice list with Paid+PDF in the mock, `SCREENS.md:291-292`) requires routes that **do not exist**: no invoice-list, no payment-method read, no subscription-detail route on the local sidecar. Also Portal requires `config.json.stripe_customer_id` to already be set (only written after a real paid checkout/webhook, `webhook.ts:60`) else **400 `NO_STRIPE_CUSTOMER`** (`portal.ts:32-34`). |
---
## 4. Auth context for screen 13 (and what "Manage" identity rests on)
Screen 13 spec (`SCREENS.md:269-278`): "Build with **Clerk** components themed to the tokens," with honest "account is optional / local-first" framing.
- **In `apps/web` (PR7 target): Clerk is NOT present.** grep `@clerk`/`ClerkProvider`/`useUser`/`signIn` in `apps/web` → **0 files**. The local sidecar registers **no auth plugin** (grep `plugins/auth` in `src/local` → 0). So **the desktop has no real logged-in identity** — tier lives in `config.json`, not behind a session.
- **Clerk IS wired, but only in the OTHER stack:** root dep `@clerk/fastify` (`package.json:45`) is consumed by `packages/server/src/plugins/auth.ts` (verifyToken + auto-provision) and `ws/gateway.ts:3` — both reachable **only from the cloud entry `src/index.ts`**, not the desktop sidecar. `apps/www` has the full Next.js Clerk surface (`sign-in`, `sign-up`, `account/page.tsx` using `<UserProfile>`, `@clerk/nextjs ^7.3.0` + `@clerk/themes ^2.4.57` in `apps/www/package.json:15-16`).
- **Therefore screen 13 in the desktop is EXTERNAL-DEP + design decision, not a wiring task.** Either (a) embed Clerk in `apps/web` for the first time (new provider, new keys, new session model — large, and contradicts "local-first / account optional"), or (b) make screen 13 a **themed informational/SSO-handoff** screen that links to `apps/www` Clerk and keeps the desktop accountless. The honesty contract leans hard toward (b): **do not render a logged-in identity (name/email/avatar) the desktop does not actually have.** (PR1 already flagged the `userName={null}` sidebar row, BUILD-PLAN.md:189.)
---
## 5. FABRICATION RISKS — must be gated off, never invented
1. **Invoices list (Manage).** No invoice route exists. A static "Invoice #1234 · Paid · PDF" list would be **fabricated billing history**. Gate: only show invoices if a real route is built against `stripe.invoices.list(customer)`; otherwise **link out to the hosted Portal** for invoices. (`SCREENS.md:292`)
2. **Payment method "VISA ···4242".** No payment-method route. The mock's "···4242" is literally Stripe's test PAN. Hardcoding it = **fake payment method**. Gate: render only from a real `paymentMethods.list`, else Portal-only. (`SCREENS.md:291`)
3. **"Next charge" / billing-cycle date.** Not returned by any local route (`sync.ts:83`, `getTier`). Inventing a date = fabrication. Gate: derive from a real subscription fetch or omit.
4. **Custom card form (4242 PAN field).** A real-looking PAN/CVC field that doesn't tokenize would be both fake AND a PCI-scope trap (see §6). Gate: never collect raw PAN in-app; hand off to hosted Checkout.
5. **Logged-in identity on Auth / sidebar (screen 13).** Desktop has no Clerk session. Showing a real name/email/avatar = fabricated identity. Gate: keep accountless or SSO-handoff; the `tierResolved`-style "unresolved" pattern (`useBilling.ts:16-21`) is the precedent.
6. **Tier shown as FREE before resolution.** `useBilling` already guards this with `tierResolved` (`useBilling.ts:36-46`) — the Plans "current plan" badge must honor it, not assume FREE.
7. **Receipt on Success.** `syncStripeCheckout` returns no receipt/amount (`sync.ts:83`). A "$19 charged" receipt line would be invented. Gate: generic "You're Pro" confirmation, or Portal link, until a real receipt fetch exists.
---
## 6. Custom card form vs hosted — PCI / scope implication (FLAG)
The screen mock shows a **custom card form** (email, card 4242, expiry/CVC, name, country — `SCREENS.md:287-289`), but the **backend only emits a hosted Stripe Checkout URL** (`checkout.ts:39-51`) and the screen note itself says **"Use Stripe Checkout/Customer Portal where possible"** (`SCREENS.md:294`).
**Implication:** A real custom PAN form means the card number touches the app's DOM → **PCI-DSS scope jumps from the trivial SAQ-A (hosted/redirect) to SAQ-A-EP or higher**, and would require Stripe.js Elements / PaymentElement client-side tokenization (none exists in `apps/web` today — grep confirms 0). For a Tauri desktop binary this is a material compliance + security burden for zero functional gain over the already-built hosted flow.
**Recommendation (for the build-plan decision):** treat the Checkout-segment card UI as a **themed visual preview/order-summary that redirects to hosted Stripe Checkout** (reuse `createCheckoutSession`), and treat "Manage" detail (payment method, invoices, cancel) as a **themed launchpad to the hosted Customer Portal** (reuse `createPortalSession`). Build new local routes (invoice-list / subscription-detail) ONLY if founder wants those rendered in-app — and even then, render strictly from live Stripe data, never placeholders.
---
## 7. One-line summary per question asked
- **create-checkout-session route?** YES — REAL (`checkout.ts:16`).
- **customer-portal route?** YES — REAL (`portal.ts:15`), but needs `stripe_customer_id` in config first.
- **invoice-list route?** NO — MUST-BUILD (or defer to hosted Portal).
- **Does the webhook flip tiers?** YES — REAL, payment-gated, idempotent (`webhook.ts:114-158`).
- **What does "Manage" need?** Either reuse the hosted Portal (REAL today) OR build 3 new local routes: invoice-list, payment-method, subscription-detail (none exist).
- **Hosted vs custom?** Hosted is the intended + already-built path; the mock's custom 4242 form is a PCI-scope trap — flag and prefer hosted.
- **Auth screen 13 backend?** Desktop has NO Clerk/identity (EXTERNAL-DEP + design call); Clerk only lives in the cloud server + `apps/www`.

View File

@@ -0,0 +1,129 @@
# PR7 Recon · 02 — Current auth / session / identity model
> Recon-only. No product code touched. Every claim cites `file:line`.
> Scope: the substrate **PR7 Auth (screen 13, Clerk) and Billing (screen 14, Stripe)** must reconcile with.
> Honesty contract (PR3PR6): every place PR7 could fabricate a logged-in identity, fake invoices/usage/payment methods is flagged for gate-off.
---
## 0. TL;DR — the architecture tension, stated plainly
Waggle today has **TWO unrelated "auth" systems**, and PR7's screen 13 belongs to neither cleanly:
1. **The local sidecar (what `apps/web` actually talks to)** authenticates with a **per-process random bearer token** — NOT a user login. There is **no account, no email, no password, no Clerk** on this path. "Who the user is" = a free-text **name** typed into onboarding (IdentityLayer), and "what tier" = a `tier` string in `config.json`. The local app runs **fully without any account** — this is literally true today, which matches the design's "an account is optional — Waggle runs fully local without one."
2. **A separate cloud/team Fastify server** (`packages/server/src/{plugins,routes,services,db}/`, distinct from `packages/server/src/local/`) **does** use **Clerk** (`@clerk/fastify`) with a real `users` DB table keyed by `clerkId`. This is the TEAMS/cloud-sync path — it is gated by `CLERK_SECRET_KEY` and is **not wired into the desktop `apps/web` UI at all**.
**Implication for PR7 Auth (screen 13):** there is **no existing login/signup UI in `apps/web`** and no client-side Clerk dependency. A "Sign in with Clerk" screen is a **MUST-BUILD net-new surface + an EXTERNAL-DEP** (Clerk publishable key + a decision about which server validates the session). The honest framing the design already calls for ("account optional, local-first") is not just copy — it is the actual current architecture, and PR7 must not regress it into a hard auth wall.
**Implication for PR7 Billing (screen 14):** the **entire Stripe flow already exists and is wired client→server** (checkout, portal, sync, tier read, trial). Billing is mostly **REAL/DERIVABLE** — the work is a themed UI over hooks that already work. The fabrication risk is the design's mocked **invoices / payment-method / "VISA ···4242"** content, which is NOT in any current API.
---
## 1. How the app authenticates TODAY (no account)
### 1.1 The token is a per-process secret, minted at sidecar boot — not a credential
- `packages/server/src/local/index.ts:1409``wsSessionToken: crypto.randomBytes(32).toString('hex')` is generated once when the sidecar's agent state is created. It is **process-lifetime**, tied to nothing about a user.
- `packages/server/src/local/index.ts:20392041` — registered into the security middleware as `sessionToken: server.agentState.wsSessionToken`.
- `packages/server/src/local/index.ts:20472051``GET /api/auth/session-token` returns `{ token: wsSessionToken }`, **auth-exempt** but **same-origin gated** via `isLocalRequest(request)` (cross-origin → 403). This is the bootstrap: the webview reads the token once, then sends it as a Bearer on every other call.
### 1.2 Server enforcement = "is this the current process's token?", nothing about identity
- `packages/server/src/local/security-middleware.ts:238``AUTH_EXEMPT_PATHS = ['/health', '/api/auth/session-token']`.
- `:340377` — bearer check: any non-exempt `/api/*` request must carry `Authorization: Bearer <sessionToken>` or it 401s with `MISSING_TOKEN`/`INVALID_TOKEN`. There is **no user lookup** — token equality is the whole check.
- `:296298` — D1: localhost is **no longer trusted by default** (`WAGGLE_TRUST_LOCALHOST=1` is the escape hatch) — because the desktop coexists with browsers/other local apps; the bearer token is what prevents any local process from driving the API.
- `:343353` — non-API GETs (the SPA shell + static assets) load token-less (chicken-and-egg bootstrap); every `/api/*` and non-GET stays gated.
- `:249254`, `:361366` — SSE streams accept the same token via `?token=` (EventSource can't set headers). Still the same per-process token.
### 1.3 The client side: attach token, refresh on 401, never a login
- `apps/web/src/boot-connect.ts:1618``adapter.connect()` fires as `main.tsx`'s first import, arming the deferral gate before any component fetch.
- `apps/web/src/lib/adapter.ts:298308``fetchSessionToken()` GETs `/api/auth/session-token` on connect and stores `this.authToken` (best-effort).
- `apps/web/src/lib/adapter.ts:444446` — every non-exempt request attaches `Authorization: Bearer ${issuedToken}`.
- `apps/web/src/lib/adapter.ts:316337, 459470` — on a 401 the adapter does ONE silent token refresh + retry (the token rotates every sidecar restart). **This is the only "session lifecycle" that exists** — it is process-rotation recovery, not user re-auth.
- `apps/web/src/lib/adapter.ts:49` — client mirror of `AUTH_EXEMPT_PATHS`.
**There is no signin/signup/logout anywhere on this path.** (grep for `SignIn|SignUp|useAuth|LoginPage` across `apps/web/src` returns only `AppShell.tsx` (a tier label) and a test file — see §4.)
---
## 2. What currently consumes "who is the user"
| Consumer | Today's source | File:line | For PR7 |
|---|---|---|---|
| **Display name** (Home greeting, sidebar user row) | `IdentityLayer.name` — a free-text name typed in onboarding, stored per-mind in SQLite | `home.ts:261270` (briefing `userName`); `AppShell.tsx:98,102,321`; `identity.ts:62100` | A Clerk identity would *supersede* this name, but the IdentityLayer name is **not** an account |
| **`HomeBriefing.userName`** (BUILD-PLAN §9 / PR1 LOW #2) | Same IdentityLayer name; optional, omitted when blank | `home.ts:406,411`; `adapter.getIdentity()` `adapter.ts:10901103` | This is the "user identity surface" the BUILD-PLAN points at — it is **identity, not auth** |
| **Tier** (everything gated) | `config.json` `tier` field (single local user) | `assert-tier.ts:2139` `readTierFromDataDir`; `settings.ts:309333` `GET /api/tier` | Tier is **device-local**, not account-bound — Billing/Auth reconciliation point (§5) |
| **Trial** | `config.json` `trialStartedAt`; effective tier downgrades TRIAL→FREE on expiry | `settings.ts:392432`; `tiers.ts:191204` | `startTrial` already exists client+server |
| **Stripe customer** | `config.json` `stripe_customer_id` (written by webhook/sync) | `webhook.ts:5262`; portal reads it | The billing "who" — a Stripe customer id, again device-local, **not** a Clerk user |
| **Team identity (cloud only)** | Clerk `clerkId` → internal `users.id` UUID | `plugins/auth.ts:2452`; `services/user-service.ts:1258` | The ONLY place a real account identity exists today — and it's **not in `apps/web`** |
**Key reconciliation fact:** tier and Stripe customer live in **`config.json` on the local device, keyed to a single anonymous local user** (`assert-tier.ts:21`, `webhook.ts:52`). They are **not** keyed to a Clerk user id. If PR7 introduces a real Clerk login, the product must decide whether tier/billing stay device-local (today's model) or migrate to account-bound (the cloud server's model). This is unresolved and is the core architectural decision PR7 surfaces.
---
## 3. The Clerk substrate that DOES exist (cloud/team server — not apps/web)
- `package.json:45``@clerk/fastify": ">=3.1.16 <4"` (root dep).
- `apps/www/package.json:1516``@clerk/nextjs": "^7.3.0"` + `@clerk/themes": "^2.4.57"` — but `apps/www` is the **landing page (Next.js), which is PR8**, not PR7's `apps/web` screen.
- `apps/web/package.json`**NO Clerk dependency** (verified, grep returns nothing).
- `packages/server/src/plugins/auth.ts:3,21,31``createClerkClient` + `verifyToken`; auto-provisions an internal user from Clerk JWT claims on first auth (`:3748`).
- `packages/server/src/services/user-service.ts``users` table CRUD keyed by `clerkId`; `upsertFromClerk`.
- `packages/server/src/local/security-middleware.ts:307309`**the local sidecar already references Clerk indirectly**: `const isTeamMode = !!process.env.CLERK_SECRET_KEY;` enables the 30-min session-inactivity timeout **only in team mode**. So "Clerk present" is already the team/cloud signal even on the local server, but it currently only toggles a timeout — it never establishes a logged-in user on the local path.
**Where a real Clerk identity slots in (PR7 Auth screen 13):** the design says "Build with Clerk components themed to the tokens" (`SCREENS.md:277`). For `apps/web` that means **adding `@clerk/clerk-react` (or `@clerk/clerk-js`) net-new**, mounting `<SignIn/>/<SignUp/>` themed to the warm tokens, and then deciding what the Clerk session *does*: (a) cosmetic/optional account that seeds the IdentityLayer name + (for Teams) unlocks cloud sync, or (b) a real session token the cloud server validates. Today nothing in `apps/web` consumes a Clerk session, so **(a) is the lower-risk, local-first-preserving path** and matches the design's "account is optional" framing.
---
## 4. Existing UI surfaces (what's there vs. what PR7 must build)
- **No Auth/Login/SignUp/Billing page exists in `apps/web`.** Glob `**/*{Auth,Login,SignIn,SignUp,Billing}*.tsx` → only `overlays/LoginBriefing.tsx`, which is the **"while you slept" overnight-work briefing overlay (SCREENS §05 hero), NOT authentication** (named "login" only because it shows on app open). Do not mistake it for an auth screen.
- `apps/web/src/components/os/AppShell.tsx:263269``tierLabel` ("Trial · 9d" / "Pro") for the sidebar user row.
- `apps/web/src/components/os/AppShell.tsx:98104,321``userName` from `adapter.getIdentity()`, degrades to "Account" when unconfigured. **This is the "user-identity surface lands (PR3)" hook the BUILD-PLAN PR1 LOW #2 deferred to** — it is fed by IdentityLayer, and PR7 Auth could optionally re-feed it from a Clerk profile.
- `apps/web/src/components/os/overlays/UpgradeModal.tsx` (imported `AppShell.tsx:37`) + `LockedFeature.tsx` — existing tier-gate upgrade prompts; `AppShell.tsx:411,422` call `adapter.createCheckoutSession(...)` directly. So an upgrade entry point already exists; PR7 Billing screen 14 is the **dedicated Plans/Checkout/Success/Manage surface** these can route into.
- `apps/web/src/test/p1b-authgate-surfaces.test.tsx` — tests the *adapter* auth gate (token/401), not a login UI.
---
## 5. Billing substrate — already REAL end-to-end (screen 14 is mostly a themed re-skin)
**Client (`apps/web`):**
- `apps/web/src/hooks/useBilling.ts` — full hook: `refreshTier` (`:34`), `syncAfterCheckout` (`:49`), `startCheckout` (`:69` → opens Stripe URL in new tab), `openPortal` (`:84`), auto-detects `?session_id=` post-checkout redirect (`:104115`). Critically `tierResolved` (`:18`) means the UI must **not** present the default `'FREE'` as fact until a real `getTier()` round-trip succeeds — an existing honesty guard PR7 must honor.
- `apps/web/src/lib/adapter.ts:26582683``syncStripeCheckout`, `createCheckoutSession('PRO'|'TEAMS')`, `createPortalSession`, `getTier`; `:2742+` `startTrial`.
**Server (`packages/server/src/stripe/` + `local/routes/settings.ts`):**
- `checkout.ts:1357``POST /api/stripe/create-checkout-session`**Stripe-HOSTED Checkout** redirect (`session.url`), success→`/payment-success?session_id=…`, cancel→`/payment-cancelled` (`:4243`). Matches design "Use Stripe Checkout/Customer Portal where possible" (`SCREENS.md:294`).
- `portal.ts``POST /api/stripe/create-portal-session` (Customer Portal) — covers the design's "Manage" state (payment method update / invoices / cancel) **for free**, no custom UI needed.
- `webhook.ts:64169``checkout.session.completed` / `customer.subscription.updated` / `customer.subscription.deleted` → writes `tier` (+ `stripe_customer_id`) into `config.json`. Idempotent + serialized (`:4045,105165`). Only grants tier when `payment_status==='paid'|'no_payment_required'` (`:120124`).
- `index.ts:71100``tierFromPriceId` / `priceIdForTier` resolve the 4-var (`STRIPE_PRICE_PRO_MONTHLY/_ANNUAL`, `…TEAMS…`) + legacy contracts.
- `settings.ts:321333``GET /api/tier` authoritative tier (+ trial days remaining).
- **All `/api/stripe/*` routes 503 `STRIPE_NOT_CONFIGURED` when `STRIPE_SECRET_KEY` is unset** (`index.ts:2543`, `checkout.ts:1720`) — so on a dev/local machine without keys, Billing must render an honest "not configured" state, not a fake checkout.
**Design-vs-build for screen 14:** the design's "Checkout: 2-col card form (card 4242…, expiry/CVC)" (`SCREENS.md:287289`) is **NOT how the current backend works** — checkout is a hosted redirect, there is no card-form endpoint and no card data ever touches Waggle. PR7 should ship the **Plans** state (real, from `getTier` + tier table) + **Success** (real, from `syncAfterCheckout`) + **Manage** (real, via Customer Portal), and either (a) drop the inline card form in favor of hosted Checkout, or (b) build Stripe Elements net-new (larger scope, more PCI surface). Recommend (a) — it matches the existing wiring and the design's own "where possible" caveat.
---
## 6. Fabrication risks for PR7 (must gate off — never invent)
1. **A logged-in identity that isn't real.** The local app has no account. The sidebar/Home already degrade `userName` to "Account" when IdentityLayer is blank (`AppShell.tsx:98104`). PR7 Auth must NOT show a fabricated "Signed in as …" when no Clerk session exists — show the optional/local-first state.
2. **Fake invoices / receipts.** No invoice API exists anywhere (`webhook.ts` writes only tier + customer id; no invoice list endpoint). The design's "invoices (Paid + PDF)" (`SCREENS.md:292`) has **no data source** — either omit, or surface them only via the **Stripe Customer Portal** (which renders real invoices), never as in-app mock rows.
3. **Fake payment method ("VISA ···4242").** No payment-method API in the sidecar. Must come from the Customer Portal or be omitted — never hardcoded.
4. **Fake usage numbers on Billing.** Tier is real (`getTier`); any "X of Y used" must come from a real source or be omitted.
5. **Presenting default `FREE` as the user's plan.** `useBilling.tierResolved` (`useBilling.ts:18`) exists precisely to prevent this — PR7 Billing must render the unresolved state while `!tierResolved`, not the FREE card.
6. **A working checkout when Stripe is unconfigured.** All `/api/stripe/*` 503 without `STRIPE_SECRET_KEY` — PR7 must render an honest disabled/"not configured" state, not a clickable fake "Subscribe".
7. **Auth gating local-first features behind a login.** The whole product runs token-only with no account today (§1). PR7 Auth must stay **optional** — wiring it as a mandatory gate would regress the local-first contract the design explicitly states.
---
## 7. REAL / DERIVABLE / MUST-BUILD / EXTERNAL-DEP summary
- **REAL** — Local bearer-token session + 401-refresh; per-process token; `GET /api/auth/session-token`; tier in `config.json` + `GET /api/tier` + trial; **entire Stripe checkout/portal/sync/webhook flow** + `useBilling` hook; IdentityLayer name → `userName`; existing `UpgradeModal`/`LockedFeature` upgrade entry points.
- **DERIVABLE** — Billing **Plans** card grid (from tier table + `getTier`); **Success** state (from `syncAfterCheckout`); **Manage** (delegate to Stripe Customer Portal). Sidebar "Signed in / Account" display from `getIdentity()`.
- **MUST-BUILD** — Themed Auth screen 13 UI (no login UI exists in `apps/web`); themed Billing screen 14 surface (no dedicated billing page exists). Decision logic for "what a Clerk session does on the local path."
- **EXTERNAL-DEP** — **Clerk** for `apps/web` (publishable key + new `@clerk/clerk-react` dep; root has only `@clerk/fastify`, `apps/www` has `@clerk/nextjs`); **Stripe** keys (`STRIPE_SECRET_KEY` + price-id env vars + `STRIPE_WEBHOOK_SECRET`) — without them all billing routes 503.
---
## 8. Open decisions PR7 must resolve (carry to the build plan)
1. **Does a Clerk login replace, supplement, or stay independent of the local token?** Today nothing in `apps/web` validates a Clerk session; the local token is what authorizes the API. Recommend: Clerk stays **optional/cosmetic + cloud-sync trigger**, local token remains the API authorizer — preserves local-first, lowest blast radius.
2. **Tier/billing keying: device-local (`config.json`, today) vs. account-bound (cloud `users` table)?** This is DESIGN_POV §4 (BUILD-PLAN §7 item 5, flagged as the PR7 blocker — "who pays for inference, BYO-key vs Waggle-metered"). Unresolved; founder decision.
3. **Checkout UI: hosted Stripe Checkout (matches current wiring) vs. inline Stripe Elements card form (matches design mock, larger scope)?** Recommend hosted — the backend already only supports it and the design says "where possible."
4. **Which server validates a Clerk session if used — the local sidecar (would need `@clerk/fastify` wired into `local/`, currently only `isTeamMode` toggle) or the separate cloud server (`packages/server/src/plugins/auth.ts`, not reachable from `apps/web`)?**

View File

@@ -0,0 +1,283 @@
# PR7 Recon · Screen 13 — Auth (Clerk, themed) · DESIGN spec
> RECON ONLY. No product code touched. Every claim cites `file:line`.
> Scope: faithful component-level breakdown of the Auth screen design so a build
> agent can implement it against the warm token system, PLUS the REAL-vs-BUILD
> reality of auth in this monorepo (which is the load-bearing surprise here).
Sources read in full:
- `docs/design_handoff_waggle_app/design-files/screens/auth.html` (181 lines)
- `docs/design_handoff_waggle_app/SCREENS.md` §13 (lines 269-278)
- `docs/design_handoff_waggle_app/design-files/styles/waggle.css` (155 lines, token grounding)
- `docs/redesign-warm-hive/BUILD-PLAN.md` §6 (PR7 row) + §7.5 (the BYO/metered gate)
- Codebase auth reality: `packages/server/src/plugins/auth.ts`, `…/local/security-middleware.ts`,
`…/services/user-service.ts`, `apps/web/src/components/os/AppShell.tsx`,
`apps/web/src/lib/adapter.ts`, `apps/www/app/sign-in/[[...sign-in]]/page.tsx`,
`apps/www/app/api/stripe/checkout/route.ts`
---
## 0. The headline (read this before building)
**The desktop app (`apps/web`) — PR7's build target — has NO authenticated user
identity and NO Clerk React SDK today.** Auth screen 13 is therefore overwhelmingly
**EXTERNAL-DEP + decision-gated**, not a re-skin of something already wired.
Two distinct server modes coexist; the design's "Clerk" assumption only matches ONE
of them, and it's NOT the one the desktop talks to:
| Mode | Auth mechanism | Has a real user account? | Where the design's screen would live |
|---|---|---|---|
| **Local sidecar** (what the Tauri desktop / `apps/web` talks to) | per-process **machine bearer token** via `GET /api/auth/session-token`, exchanged so loopback callers can't drive the API. NOT a login. (`packages/server/src/local/security-middleware.ts:235-377`, `:238`) | **No.** "Identity" is the local IdentityLayer name the user types in onboarding (`adapter.getIdentity()``/api/identity`, `IdentityResponse.name`), a memory record, not an account. (`apps/web/src/lib/adapter.ts:1090-1095`; `tauri-bindings.ts:133-145`) | n/a today — there is no `/auth` route in `apps/web` (grep for `'/auth'`/`appId.*auth`**No matches**) |
| **Cloud / Team server** (`packages/server/src/index.ts` + `plugins/auth.ts`) | **real Clerk**`verifyToken()`, `clerkClient.users.getUser()`, Drizzle `users` table, auto-provision on first auth (`packages/server/src/plugins/auth.ts:3,19-52`; `services/user-service.ts:30-58`) | Yes (Clerk user → internal UUID) | n/a in `apps/web` either |
| **`apps/www`** (Next.js landing) | **real Clerk UI**, hosted `<SignIn/>`/`<SignUp/>` catch-all pages, themed via `<ClerkProvider>` (`apps/www/app/sign-in/[[...sign-in]]/page.tsx:1,17-23`); `@clerk/nextjs@^7.3.0` + `@clerk/themes@^2.4.57` (`apps/www/package.json:15-16`) | Yes | **This is the only place Clerk's themeable React UI already exists.** |
So the build decision PR7 must surface: **does screen 13 ship as a real auth flow in
the desktop at all, or is desktop auth always optional/local and "sign in for sync"
links out to the `apps/www` Clerk flow?** The design copy itself ("account is optional —
Waggle runs fully local without one") leans toward the latter. See §6 Decisions.
---
## 1. Exact layout (split: brand-panel left + form right)
From `auth.html`:
- **Top control bar** (`.controls`, `auth.html:78-87`) — concept-harness chrome: a label
`Auth · Clerk · state` (`:79`), a 4-way segmented state switcher
`Sign in / Sign up / Verify / SSO` (`:80-85`), and a theme toggle button (`:86`).
**This bar is concept scaffolding for previewing states — NOT product UI.** In the
real build the "state" is route/Clerk-flow-driven, not a manual segmented control.
- **Split grid** (`.split`, `:89`; CSS `:20`) — `grid-template-columns: 1.05fr 1fr`
(brand panel slightly wider than the form).
- **Left brand panel** (`.brandside`, `:90-101`; CSS `:22-33`):
- 56px padding, `linear-gradient(160deg, var(--bg-2), var(--bg))`, right border
`--line-soft`, full-bleed honeycomb texture `.comb` masked by a radial gradient
at 30%/30% (CSS `:23`).
- Three vertical zones via `justify-content:space-between`: **brand lockup** (hex "W"
mark + "Waggle" wordmark, `:92`), **pitch** (h2 + p, `:93-96`), **trust lines**
(`:97-100`).
- **Hidden below 820px** — `@media (max-width:820px){ .brandside{display:none} }`
(CSS `:73`). Mobile = form only.
- **Right form panel** (`.formside`, `:103-152`; CSS `:36-37`): centered, `max-width:380px`
card, scrollable. Holds the four state views (`.view`, only one `.on` at a time, CSS `:72`).
---
## 2. Every state — verbatim copy + behavior
### 2a. Sign in (`data-view="signin"`, `auth.html:106-117`) — default
- Heading **"Welcome back"**; sub **"New to Waggle? Create an account"** (link → signup) (`:107`).
- **SSO block** (`.sso`, `:108-111`): two buttons — **"Continue with Google"** (mono "G"
badge) and **"Continue with Apple"** (mono "⌥" badge).
- Divider **"or"** (`.divider`, `:112`).
- **Email** field, demo value `mara@egzakta.com` (`:113`).
- **Password** field with a `lrow` header: label + **"Forgot?"** link (→ verify view in
the demo; in product → Clerk reset) (`:114`).
- Primary **"Sign in"** button (`data-go="home"` → routes to Home) (`:115`).
- Fineprint: **"By continuing you agree to the Terms & Privacy Policy."** (`:116`).
### 2b. Sign up (`data-view="signup"`, `:120-132`) — carries the local-first note
- Heading **"Create your hive"**; sub **"Already have an account? Sign in"** (`:121`).
- **`.localnote` honey banner** (`:122`; CSS `:67-70`) — THE load-bearing trust copy:
> **"You don't need this to start."** Waggle works locally right away — create an
> account only when you want sync or a team.
- **SSO block**: single **"Sign up with Google"** (`:124`).
- Divider **"or"** (`:126`).
- Fields: **Name** (demo `Mara Kovač`), **Email** (placeholder `you@company.com`),
**Password** (placeholder **"At least 10 characters"**) (`:127-129`).
- Primary **"Create account"** (→ verify) (`:130`).
- Fineprint: **"We'll send a code to verify your email."** (`:131`).
> Password rule "At least 10 characters" (`:129`) is design copy. Real minimum is
> Clerk-policy-driven — do NOT hardcode "10" in validation; mirror whatever the Clerk
> instance enforces, or omit the count.
### 2c. Verify — 6-box OTP (`data-view="verify"`, `:135-140`)
- Heading **"Check your email"**; sub **"We sent a 6-digit code to mara@egzakta.com"**
(the email is bolded in `--text-2`) (`:136`).
- `.otp` row of **6 single-char inputs** (`:137`; CSS `:60-64`): 48×56px, mono 22px,
`maxlength=1`, `inputmode="numeric"`; a `.filled` class flips border + text to honey
on a non-empty box.
- **Auto-advance / backspace nav** (the explicit design requirement), in the demo script
(`:163-169`): `input` event focuses the next box when filled; `keydown` Backspace on an
empty box focuses the previous box. The demo pre-fills boxes 0-2 with `[2,4,9]`.
- Primary **"Verify & continue"** (→ Home) (`:138`).
- **"Didn't get it? Resend code · Use a different email"** (→ back to sign in) (`:139`).
### 2d. SSO / enterprise (`data-view="sso"`, `:143-150`)
- Heading **"Single sign-on"**; sub **"Use your organization's identity provider."** (`:144`).
- Field **"Work email or organization"** (placeholder `you@company.com`) (`:145`).
- Primary **"Continue with SSO"** (`:146`).
- Divider **"enterprise"** (`:147`).
- **Muted `.localnote`** (neutral, not honey — `background:var(--bg-2)`) (`:148`), verbatim:
> SAML, SCIM provisioning, and audit logs are available on **Teams** and **KVARK**.
> **Talk to sales →**
- Back link **"← Back to sign in"** (`:149`).
### 2e. Brand-panel pitch + trust copy (verbatim, `:93-100`)
- h2: **"Your work follows you, *everywhere.*"** ("everywhere." in honey via `em`, CSS `:29`).
- p: **"Sign in to sync your hive across devices, collaborate with a team, and pick up any
project exactly where you left off — on any machine."**
- Trust line 1 (shield icon): **"An account is optional — Waggle runs fully local without one"**
- Trust line 2 (arrow icon): **"Your memory stays yours; sign-in only adds sync"**
---
## 3. Warm tokens + primitives used (for faithful build)
All from `waggle.css` (dark `:9-61`, light `:63-101`). The auth HTML uses these named
tokens directly:
| Primitive | Tokens (from `auth.html` `<style>` + `waggle.css`) |
|---|---|
| Brand-panel bg | `linear-gradient(160deg, --bg-2, --bg)`; border `--line-soft` (`auth.html:22`) |
| Honeycomb texture | `.comb` data-URI honey @ 5% stroke, radial mask (CSS `:119-122`) |
| Hex mark | `.hex` clip-path (`waggle.css:117`) + `linear-gradient(150deg,--honey-bright,--honey-deep)` + `--honey-glow` (`auth.html:25`) |
| SSO buttons | `--surface` bg, `--line-strong` border; hover → `--honey-line` + `--surface-2` (`auth.html:44-45`) |
| Divider | flex rule, `--line-soft` lines, mono `--text-dim` label (`auth.html:47-48`) |
| Inputs | `--surface` bg, `--line` border, `--r:11px`; focus → `--honey-line` + `--honey-glow` (`auth.html:52-53`) |
| OTP boxes | mono, `--r:12px`; `.filled``--honey-line` + honey text (`auth.html:62-64`) |
| Primary submit | `--honey` bg, text `#1a1407`; hover → `--honey-bright` (`auth.html:56-57`) |
| Honey trust banner | `.localnote``--honey-wash` bg + `--honey-line` border (`auth.html:68`) |
| Neutral enterprise banner | `.localnote` overridden to `--bg-2` + `--line-soft` (`auth.html:148`) |
| Links / accents | `--honey`; fineprint `--text-dim` (`auth.html:41,55,58`) |
| Focus ring (global) | `:focus-visible{outline:2px solid --honey}` (`waggle.css:125`) |
| Fonts | `--sans` Hanken Grotesk, `--mono` JetBrains Mono (`waggle.css:51-53`) |
In the real build these map to the **PR1-landed warm tokens** in `apps/web`
(`BUILD-PLAN.md §3.1`, already shipped per MEMORY.md — `index.css` carries the verbatim
`waggle.css` names + the shadcn HSL recolor). So **no new tokens are needed** — the build
re-skins Clerk/custom components against the already-present token set. Honey "#1a1407"
button-foreground is the same `--primary-foreground` PR1 set (`BUILD-PLAN.md:67`).
`#1a1407` (honey-button text) and `data-theme` theming are app-global; the screen
inherits dark default + the warm-paper light variant for free.
---
## 4. Clerk's themeable components vs custom
Per SCREENS.md §13: **"Build with Clerk components themed to the tokens."** Mapping the
design's pieces to what Clerk's `appearance` API covers:
| Design piece | Clerk coverage | Notes |
|---|---|---|
| Sign in (Google/Apple SSO + email/pw) | **`<SignIn/>`** | Social buttons, email/pw, "Forgot?" reset are first-class. Theme via `appearance.variables` (`colorPrimary``--honey`, `colorBackground``--surface`, etc.) + `elements` overrides. `@clerk/themes` already a www dep (`apps/www/package.json:16`). |
| Sign up + local-first note | **`<SignUp/>`** + **custom** | The form is Clerk; the **honey `.localnote` "you don't need this to start"** banner is custom chrome placed above/around `<SignUp/>`. |
| Verify 6-box OTP (auto-advance/backspace) | **Clerk built-in** | Clerk's email-code step renders its own OTP input with auto-advance. Re-skinning to the exact 48×56 honey boxes needs `elements.otpCodeField*` overrides (or Clerk Elements / a fully custom flow if pixel-parity is required). |
| SSO / enterprise (SAML/SCIM → Teams/KVARK) | **partial Clerk + custom** | Clerk Enterprise SSO exists but is a paid Clerk feature + per-org config. The design's panel is mostly a **custom "Talk to sales" CTA** (KVARK funnel), not a live SAML form. Safe build: custom panel, link to sales. |
| Left brand panel + pitch + trust lines | **fully custom** | Pure layout chrome around the Clerk `<SignIn/>`/`<SignUp/>` card. |
**To match the warm design, two integration styles are possible:**
1. **Themed Clerk prebuilt** (`<SignIn appearance={…}/>`) wrapped in the custom split
layout — fastest, matches SCREENS.md's instruction, but OTP/element pixel-parity is
limited to what `appearance.elements` exposes.
2. **Clerk Elements / headless** (`useSignIn`, `useSignUp`) feeding the design's exact
custom inputs/OTP/buttons — full visual control, more code, the only way to get the
exact 48×56 honey OTP boxes + custom SSO buttons.
The brand panel, dividers, local-first banner, and enterprise→sales CTA are **custom in
either case**.
---
## 5. Honesty contract — where Auth could fabricate (MUST be gated off)
Auth is the single highest-risk screen for fabrication because the desktop has no real
account. Each of these must be **real or absent — never invented**:
1. **A logged-in identity that isn't real.** The demo hardcodes `mara@egzakta.com` /
`Mara Kovač` (`auth.html:113,127,136`). A build MUST NOT pre-fill or display a fake
signed-in user. The desktop's only "identity" is the local IdentityLayer name
(`adapter.getIdentity()`, `apps/web/src/lib/adapter.ts:1090`), which is **not** an
authenticated account and must never be rendered as "signed in".
2. **Fake SSO success.** Google/Apple/SSO buttons that "succeed" without a real Clerk
(or any) provider configured are fabrication. If Clerk isn't wired in the desktop,
these buttons must be honestly disabled / "coming soon" / route to `apps/www`, not
fake a session. (No Clerk publishable key path exists in `apps/web` today.)
3. **Fake OTP verification.** The demo's "Verify & continue" advances on any input
(`auth.html:138,164`). Real verify must check a real code via Clerk; otherwise the
verify state must not claim to have verified anything.
4. **"Continue → Home" as a real auth boundary.** In the demo all submits just navigate
to Home (`auth.html:171-172`). The desktop already has a **real structural auth gate**
(the session-token `ensureReady()` contract, `adapter.authgate.test.ts`) — but that
gates the *local sidecar*, not a user login. The Auth screen must not imply a login
happened when only the local app opened.
5. **SAML/SCIM as live.** The enterprise panel names SAML/SCIM/audit logs
(`auth.html:148`). These are Teams/KVARK/Clerk-Enterprise features — the panel is a
**sales CTA**, and must stay one unless those are genuinely provisioned. Do not render
a SAML form that does nothing.
**Gate-off rule:** if Clerk is not configured for the desktop, the entire authenticated
path (SSO, email/pw, OTP, SSO/org) should degrade to the honest local-first framing the
design itself already provides ("an account is optional — Waggle runs fully local") and
a single "Sign in for sync →" link to the real flow, rather than a non-functional
look-alike.
---
## 6. Decisions a build agent must get answered first
1. **Does desktop auth ship at all, or link out?**
- Options: (a) full Clerk in `apps/web` (add `@clerk/clerk-react` + publishable key +
a `/auth` route — none exist today); (b) desktop stays local-only, "Sign in for sync"
deep-links to the existing `apps/www` Clerk flow (`apps/www/app/sign-in/...`); (c)
embed/redirect to `apps/www` in a webview.
- Recommendation: **(b)** for first ship — matches "account is optional", reuses the
real, already-themed `apps/www` Clerk surface, and avoids standing up a second Clerk
React integration + token bridge into the local sidecar (which currently authenticates
with a *machine* token, not a *user* token).
- Blast radius: large if (a) — new dep, new route, new token-exchange between Clerk
user-JWT and the local bearer; small if (b)/(c).
2. **BYO-key vs Waggle-metered (DESIGN_POV §4 / BUILD-PLAN §7.5 #5).** Explicitly flagged
as **blocking Billing/PR7** (`DESIGN_POV.md:62-70`, `:88-90`; `BUILD-PLAN.md:165-166`).
It reshapes whether "sign in" is even required to use models (BYO = local key, no
account needed; metered = account + payment up front). **Settle before building 13/14.**
- Blast radius: shapes Auth (is sign-in required for inference?), Onboarding model gate,
Billing, Usage. Founder decision, not a build choice.
3. **OTP fidelity: themed Clerk prebuilt vs Clerk Elements/headless.** Pixel-exact 48×56
honey OTP boxes need headless; "good enough" needs only `appearance` overrides.
- Recommendation: themed prebuilt first (ships SCREENS.md's instruction), upgrade to
Elements only if review demands the exact boxes. Blast radius: small/local.
4. **SSO/enterprise panel = sales CTA only (no live SAML).** Recommendation: keep it a
custom "Talk to sales → KVARK/Teams" panel; do not implement live SAML in PR7.
Blast radius: small.
---
## 7. REAL vs DERIVABLE vs MUST-BUILD vs EXTERNAL-DEP (screen 13)
| Feature | Status | Evidence / note |
|---|---|---|
| Warm tokens + primitives the screen needs | **REAL** | PR1 landed verbatim `waggle.css` tokens in `apps/web` (`BUILD-PLAN.md §3.1`, MEMORY.md PR1). No new tokens. |
| Split brand panel, pitch, trust lines, dividers, local-first banner, enterprise CTA | **MUST-BUILD** (custom chrome, low risk) | Pure layout/copy; no backend. All copy verbatim in §2/§4 above. |
| Clerk `<SignIn/>`/`<SignUp/>`/OTP UI in `apps/web` | **EXTERNAL-DEP** | No `@clerk/clerk-react` in `apps/web`; no publishable key; no `/auth` route (grep: no matches). Clerk React UI exists ONLY in `apps/www` (`apps/www/app/sign-in/[[...sign-in]]/page.tsx`). |
| Real user account / login / session | **EXTERNAL-DEP** | Real Clerk auth lives in cloud/team server (`packages/server/src/plugins/auth.ts:3,31-48`) + `users` table (`services/user-service.ts`). Desktop sidecar auth is a **machine bearer token**, not a user (`local/security-middleware.ts:235-377`). |
| Local "identity" (name) for the user row | **REAL but NOT an account** | `adapter.getIdentity()``/api/identity` → IdentityLayer name (`adapter.ts:1090`; `tauri-bindings.ts:133-145`); used in `AppShell.tsx:98-108`, degrades to "Account". Must NOT be shown as "signed in". |
| SSO with Google/Apple | **EXTERNAL-DEP** | Clerk social providers; need Clerk + OAuth app config. Not wired in desktop. |
| SAML / SCIM (enterprise) | **EXTERNAL-DEP** (Teams/KVARK/Clerk-Enterprise) | Design panel is a sales CTA, not a live form (`auth.html:148`). |
| "Continue → Home" navigation | **DERIVABLE** | Routes to `/home`; desktop already has the structural sidecar auth gate (`adapter.authgate.test.ts`) but that is not a user login. |
| Theme toggle / segmented state switcher (top bar) | **N/A — concept scaffolding** | `.controls` is harness chrome for previewing states (`auth.html:78-87`), not product UI. |
---
## 8. One-paragraph build brief (for the implementer)
Build screen 13 as a **custom warm split layout** (left brand panel: hex "W" + honeycomb
`.comb` + verbatim pitch/trust copy from §2e; right: a centered `max-width:380px` card)
in `apps/web`, against the **already-present PR1 warm tokens** (no new tokens). The form
itself is **EXTERNAL-DEP on Clerk**, which is wired only in `apps/www` today — so the
**first, honest ship is local-first**: the desktop stays usable without an account
(reuse the design's own "you don't need this to start" `.localnote`), and a single
**"Sign in for sync →"** links to the real, already-themed `apps/www` Clerk flow rather
than a non-functional Clerk look-alike in the desktop. If founder confirms full in-app
Clerk (decision §6.1a), add `@clerk/clerk-react` + a publishable key + a `/auth` route +
a user-JWT→local-sidecar token bridge, and theme `<SignIn/>`/`<SignUp/>`/OTP via
`appearance` (Elements only if pixel-exact OTP boxes are required). **Never** render a
fabricated signed-in identity, fake SSO/OTP success, or a dead SAML form (§5). The
**BYO-vs-metered decision (§6.2) blocks this screen and Billing** and must be settled
first.

View File

@@ -0,0 +1,271 @@
# PR7 Recon — Screen 14: Billing (Stripe) DESIGN spec
> RECON ONLY. No product code touched. Topic owner: the Billing screen DESIGN spec
> (`billing.html`) + SCREENS.md §14, cross-checked against the REAL Stripe wiring in the
> monorepo so PR7 knows what is wired vs what must be gated/faked.
>
> Sources read in full:
> - `docs/design_handoff_waggle_app/design-files/screens/billing.html` (247 lines)
> - `docs/design_handoff_waggle_app/SCREENS.md` §14 (lines 282295)
> - `docs/redesign-warm-hive/BUILD-PLAN.md` (§6 roadmap, §7.5 BYO-vs-metered flag)
> - Backend Stripe: `packages/server/src/stripe/{index,checkout,portal,webhook,sync}.ts`
> - Tier system: `packages/shared/src/tiers.ts`
> - Current billing UI: `apps/web/src/hooks/useBilling.ts`,
> `apps/web/src/components/os/overlays/UpgradeModal.tsx`,
> `apps/web/src/components/os/apps/SettingsApp.tsx` (Billing tab, ~L516650)
> - Tier route: `packages/server/src/local/routes/settings.ts` (`GET /api/tier`, L321341)
> - Identity name: `packages/server/src/local/routes/home.ts` (L258271, IdentityLayer)
> - Landing pricing: `apps/www/app/_components/Pricing.tsx`
---
## 0. TL;DR for the PR7 builder
The design is a **4-state segmented Stripe billing flow**: Plans, Checkout, Success, Manage
(`billing.html:117122`). The monorepo already has a **fully wired, real Stripe backend** for
*Plans → checkout-redirect → sync → tier* and a **hosted Customer Portal** for *Manage*. But the
design's Checkout, Success, and Manage states render a **custom card form, a fabricated receipt,
a fabricated payment method, and a fabricated 3-line invoice list** — **none of which have a data
source in this codebase, and none of which we should hand-build** (PCI + fabrication risk).
**The governing instruction is already in the design itself** (`SCREENS.md:294`):
> "Use Stripe Checkout/Customer Portal where possible; theme to tokens."
So PR7's faithful-but-honest interpretation: **build the Plans state for real** (it maps 1:1 to the
existing checkout route), and **treat Checkout/Success/Manage's in-app chrome as Stripe-hosted
redirects**, not as locally-rendered card forms / invoice tables. The custom card form in the HTML
is a **mockup of what Stripe Checkout shows** — we must not reimplement it.
**Blocking dependency (already flagged):** BUILD-PLAN §7 open-decision #5 — DESIGN_POV §4
"who pays for inference (BYO-key vs Waggle-metered)" — must be decided before PR7. The Plans copy
("you only pay for scale — no feature-count games") leans metered/scale framing; the product today
is BYO-key (Settings model keys). This is a **copy + product-positioning fork**, not just a screen.
---
## 1. State 1 — PLANS (`billing.html:127159`) — **REAL, ship it**
### Verbatim copy
- Segmented control labels: `Plans` / `Checkout` / `Success` / `Manage` (`:118121`); top eyebrow
`Billing · Stripe · state` (`:116`).
- Header H1: **"Upgrade your _hive._"** (`:130`).
- Subhead: **"Memory is free forever. You only pay for scale — no feature-count games."** (`:131`).
- Cycle toggle: **`Monthly`** | **`Annual 20%`** (`:132`); the `20%` is a `.save` span in
`--healthy` green.
### The 3 cards (verbatim)
| Card | Tag | Price (mo) | Price (yr) | Unit suffix | Tagline | Feature list | CTA |
|---|---|---|---|---|---|---|---|
| **Solo** | `Current` (work-blue `.curtag`, `:136`) | **$0** | $0 | `/ forever` | "For individuals exploring an AI workspace." | Personal memory graph · All major LLMs + local · Local-first by default | **"Your plan"** (disabled, `:141`) |
| **Pro** | **"Most popular"** (honey `.pop`, `:144`) `.feat` honey border+glow | **$19** | **$15** | `/ month``/ mo · billed yearly` | "For power users compounding across projects." | Everything in Solo · Sync across devices · Marketplace skills & connectors · Self-evolving skills | **"Choose Pro"** (honey, `data-go="checkout"`) |
| **Teams** | — | **$49** | **$39** | `/ seat / mo``/ seat · yearly` | "Shared memory without losing privacy." | Everything in Pro · Shared team memory · WaggleDance multi-agent · SSO & role-based access | **"Choose Teams"** (ghost) |
- Annual prices come from `data-yr` attributes; the cycle toggle JS swaps `pp` innerHTML and special-cases Teams' suffix (`:243`). So **$15 Pro / $39 Teams annual are the design's stated annual-equivalent monthly numbers** (20% of $19→$15.20 rounded to $15; 20% of $49→$39.20 rounded to $39).
- "Most popular" badge is **honey** (`--honey` bg, `#1a1407` text, `:38`); "Current" badge is **work-blue** (`--work`, `:39`).
### REAL backing
- **Prices match `tiers.ts:712` exactly**: FREE $0 / PRO $19/mo / TEAMS $49/seat. ✅ No drift.
- **"Choose Pro/Teams" → real route**: `adapter.createCheckoutSession('PRO'|'TEAMS')`
(`adapter.ts:2667`) → `POST /api/stripe/create-checkout-session` (`checkout.ts:1356`) →
`stripe.checkout.sessions.create({mode:'subscription', allow_promotion_codes:true, ...})`
returns hosted `session.url`. **This already works** when `STRIPE_SECRET_KEY` + a price ID env are set.
- **Monthly/Annual toggle is REAL-capable but currently NOT wired in-app.** The backend
`priceIdForTier(tier, billingPeriod)` (`index.ts:91100`) already resolves
`STRIPE_PRICE_PRO_MONTHLY/_ANNUAL` + `STRIPE_PRICE_TEAMS_MONTHLY/_ANNUAL`. But
`adapter.createCheckoutSession(tier)` (`adapter.ts:2667`) sends **no `billingPeriod`** → always
monthly. **MUST-BUILD (small):** thread `billingPeriod` through the adapter + hook to honor the
toggle. `apps/www/app/_components/Pricing.tsx:9,95,165` already has the monthly/annual toggle pattern to copy.
- "Solo = Current / Your plan (disabled)" — the current-plan marker is REAL: `useBilling().tier`
+ `tierResolved` (`useBilling.ts:1337`); SettingsApp already renders a tier badge from this
(`SettingsApp.tsx:537561`).
**Verdict: Plans is REAL/DERIVABLE — the highest-value, lowest-risk part of PR7.**
---
## 2. State 2 — CHECKOUT (`billing.html:161185`) — **DO NOT hand-build the card form**
### Verbatim copy (left "Payment details" panel, `:163175`)
- H2 **"Payment details"**.
- Email field, value `mara@egzakta.com` (**fabricated identity**).
- "Card information" → `1234 1234 1234 1234` placeholder, value `4242 4242 4242 4242` (**fake Stripe test card**), `VISA` brand chip.
- Expiry `MM / YY` value `08 / 28`; CVC value `•••`.
- "Name on card" value `Mara Kovač` (**fabricated**).
- "Country" value `Germany` (**fabricated**).
- Secure line (`:172`): lock icon + **"Encrypted & secure. We never store your card — Stripe does."**
- Pay button (`:173`): **"Pay $19.00 / month"** → `data-go="success"`.
- Footer (`:174`): **"Powered by _Stripe_ · cancel anytime"**.
### Verbatim copy (right "Order summary" panel, `:176184`)
- H2 **"Order summary"**.
- Plan row: hex "W" mark + **"Waggle Pro"** / **"Monthly · renews Jul 14"** (`:178`).
- Line: **"Pro plan" — "$19.00"** (`:179`).
- Promo row: input placeholder **"Promo code"** + **"Apply"** button (`:180`).
- Line: **"Tax (est.)" — "$0.00"** (`:181`).
- Total line: **"Due today" — "$19.00"** (`:182`).
- Guarantee (`:183`): **"14-day free trial · you won't be charged until Jun 28"**.
### Reality check — THIS IS THE CORE TENSION
- **There is NO custom-card-form backend, and there must not be one.** The real flow is a
**redirect to Stripe-hosted Checkout**: `checkout.ts` returns `session.url`, and
`useBilling.startCheckout()` does `window.open(url, '_blank')` (`useBilling.ts:6981`). The card
fields, brand detection, promo `Apply`, and live tax are **all Stripe's hosted page**, not ours.
- **`SCREENS.md:294` explicitly says "Use Stripe Checkout/Customer Portal where possible."** So the
HTML's 2-col card form is a **visual mock of Stripe Checkout** — the honest PR7 build is:
*"Choose Pro" → spinner/redirect → Stripe-hosted Checkout (themeable via Stripe's Branding
settings, NOT our DOM).* We do **not** collect card/email/name/country in-app (PCI scope + the
fields have no API to POST to).
- **Promo codes ARE real** end-to-end: `allow_promotion_codes:true` (`checkout.ts:44`) — but they're
entered on Stripe's page, not our `Apply` button.
- **Trial-aware "Due today / won't be charged until"** is DERIVABLE from `trialDaysRemaining` +
`trialStartedAt` (`/api/tier`, `settings.ts:332333`) for a *plans-page hint*, but the binding
"Due today $19 / charged Jun 28" on the checkout page itself is **Stripe-rendered** (Stripe knows
the actual trial config on the price). A hardcoded "Jun 28" / "Jul 14" in our UI would be fabrication.
**Verdict: MUST-NOT-HAND-BUILD. Replace the custom card panel with the existing redirect-to-Stripe
flow. The order-summary panel can be a real pre-checkout summary (plan + price from `tiers.ts`),
but any date/tax/"due today" line must come from Stripe or be omitted — never invented.**
---
## 3. State 3 — SUCCESS (`billing.html:187199`) — **receipt = FABRICATED, gate it**
### Verbatim copy
- Healthy-green check **ring** (`.ring`, `--healthy-wash` bg, `:189`).
- H1 **"You're _Pro._"** (`:190`).
- Body (`:191`): **"Your hive just leveled up — _sync, the marketplace, and self-evolving skills_
are live. Your trial runs 14 days; we'll remind you before the first charge."**
- Receipt card (`:192197`):
- **"Plan" — "Waggle Pro · Monthly"**
- **"Trial ends" — "Jun 28, 2026"** (**fabricated date**)
- **"Then" — "$19.00 / month"**
- **"Receipt" — "Emailed →"** (honey, clickable; **no real email-receipt feature in-app**)
- CTA (`:198`): **"Start using Pro →"** (`data-go="home"`) + **"Manage billing"** (`data-go="manage"`).
### Reality check
- **The success *trigger* is REAL**: after Stripe redirect, the app detects `?session_id=` and calls
`POST /api/stripe/sync` (`useBilling.ts:104115``sync.ts:1890`), which **payment-gates**
(`session.payment_status === 'paid' || 'no_payment_required'`, `sync.ts:46`) and updates the tier.
There IS a `/payment-success?session_id=…` success_url already (`checkout.ts:42`). So a real
"You're Pro" confirmation **can** render off the synced tier.
- **The receipt block is fabricated.** `/api/stripe/sync` returns only `{ tier, customerId }`
(`sync.ts:83`). **No "trial ends" date, no "$/mo then" line, no receipt number/email** is returned.
"Trial ends Jun 28" and the "Emailed →" receipt link have **no data source** — rendering them as
shown would invent facts. ("Receipt emailed" is even arguably true *only* if Stripe email receipts
are enabled on the account — out of our control.)
**Verdict: Build the success state from the SYNCED TIER only ("You're Pro" + CTA buttons).
GATE OFF the receipt rows (Plan/Trial-ends/Then/Receipt) unless sourced from Stripe — they are
fabrication risks. Trial copy must be driven by real `trialDaysRemaining`, not a hardcoded "14 days
/ Jun 28."**
---
## 4. State 4 — MANAGE (`billing.html:201218`) — **invoice list + payment method = FABRICATED; use Customer Portal**
### Verbatim copy
- H1 **"Billing"** (`:203`).
- Current-plan card (`:204209`, honey border):
- **"Waggle Pro"** / **"$19.00 / month · renews Jul 14, 2026"** + **"● Active"** badge (honey).
- Row **"Billing cycle" — "Monthly _Switch to annual (20%)_"** (the `.chg` link is honey).
- Row **"Payment method" — "VISA ···· 4242" _Update_** (`:207`).
- Row **"Next charge" — "$19.00 on Jul 14"** (`:208`).
- Invoices card (`:210215`):
- Header **"Invoices"**.
- 3 rows, each: date · `$19.00` (mono) · **"Paid"** (healthy pill) · **"PDF ↓"** download link:
- **Jun 14, 2026** · $19.00 · Paid · PDF
- **May 14, 2026** · $19.00 · Paid · PDF
- **Apr 14, 2026** · $19.00 · Paid · PDF
- Footer actions (`:216`): **"Change plan"** (ghost, `data-go="plans"`) + **"Cancel subscription"** (danger/red).
- Footer note (`:217`): **"Subscription managed securely via _Stripe_."**
### Reality check
- **`renews Jul 14`, `VISA ···· 4242`, `Next charge $19.00 on Jul 14`, and ALL 3 invoices are
fabricated.** Grep confirms **no invoices route, no payment-method route, no `invoices.list` /
`customers.retrieve` / `paymentMethods` call anywhere in `packages/server/src`.** `/api/stripe/sync`
+ `/api/tier` return **no renewal date, no card brand/last4, no next-charge, no invoice history.**
- **What IS real for Manage:** the **Stripe Customer Portal**. `createPortalSession()`
(`adapter.ts:2675`) → `POST /api/stripe/create-portal-session` (`portal.ts:1451`, `requireTier('PRO')`,
reads `stripe_customer_id` from config.json) → returns hosted `billingPortal` URL. **The portal IS
where "Update payment method, view invoices, cancel subscription" actually happens** — and the
current SettingsApp already says exactly that (`SettingsApp.tsx:632`: *"Update payment method, view
invoices, or cancel your subscription via the Stripe customer portal."*).
- **"Switch to annual (20%)" / "Change plan" / "Cancel subscription"** → all **belong in the
Customer Portal** (or a fresh checkout for an upgrade). Building in-app cancel/swap buttons that
hit Stripe write-APIs directly is out of scope and risky; the portal is the sanctioned surface.
**Verdict: The Manage state's in-app "current plan / cycle / payment method / next charge / invoice
table" must be REPLACED by (a) a real current-plan header off `useBilling().tier` and (b) a single
"Manage subscription via Stripe" button that opens the Customer Portal. The fabricated invoice list,
card number, renewal/next-charge dates, and PDF links MUST be gated off — there is no data for them.**
---
## 5. The custom-card-form-vs-Stripe-Checkout tension (explicit, per task)
| Design HTML shows | Codebase reality | PR7 resolution |
|---|---|---|
| In-app 2-col card form (email/card/expiry/CVC/name/country) | No card-capture endpoint; PCI-out-of-scope by design (`checkout.ts` only mints a hosted session) | **Redirect to Stripe-hosted Checkout** (existing `startCheckout``window.open(session.url)`). Theme via Stripe Branding, not our DOM. |
| In-app promo `Apply`, live `Tax (est.)`, `Due today` | `allow_promotion_codes:true` (real) but applied on Stripe's page; tax/proration is Stripe-computed | Promo/tax/due-today live on the **hosted page**. An in-app pre-summary may show plan+list price from `tiers.ts` only. |
| In-app invoice table + PDF + payment method + cancel | No invoices/payment-method/cancel route exists | **Stripe Customer Portal** (existing `openPortal`). |
| Success receipt (#, trial-end, "emailed") | `/sync` returns `{tier, customerId}` only | Confirm off synced tier; **gate the receipt block**. |
`SCREENS.md:294` ("Use Stripe Checkout/Customer Portal where possible; theme to tokens") **is the
contract**: the HTML card form / invoice table are **fidelity mockups of Stripe's hosted surfaces**,
not a spec to reimplement. PR7 builds the **Plans** screen + the **two redirect entry points**
(Checkout → hosted; Manage → portal) + an **honest post-redirect success** state, all themed.
---
## 6. Fabrication-risk register (what PR7 could silently invent — gate OFF)
1. **Logged-in identity** — design hardcodes `mara@egzakta.com` / `Mara Kovač`. There is **no real
account email** in any contract. The only display name available is `IdentityLayer.get().name`
(memory-derived, often undefined — `home.ts:261267`), surfaced as `HomeBriefing.userName`. **An
email or "name on card" must NOT be invented.** (Note: Auth/Clerk is screen 13's job; until Clerk
lands there is no authenticated email at all.)
2. **Invoice list** (3× $19 Paid + PDF) — **no invoices route; pure fabrication.** Gate off → Customer Portal.
3. **Payment method** (`VISA ···· 4242`) — **no payment-method route; fabrication.** Gate off → Portal.
4. **Receipt number / "Emailed →" / "Trial ends Jun 28"**`/sync` has none of these. Gate off.
5. **Renewal / next-charge dates** (`renews Jul 14`, `Next charge $19.00 on Jul 14`) — no date in any
contract. **Do not hardcode dates.** Trial dates only via real `trialDaysRemaining`/`trialStartedAt`.
6. **`$15` / `$39` annual prices** — these are the **design's** annual numbers (20% rounded). The
*authoritative* charge is whatever the `STRIPE_PRICE_*_ANNUAL` price says. Display the design's
marketing number is fine; **the actual charged amount must come from Stripe**, never asserted by us.
7. **Test card `4242 4242 4242 4242`** — fine as a placeholder in a *mock*, but must never appear in
the real (hosted) flow; it's Stripe's own test PAN.
---
## 7. REAL vs MUST-BUILD vs EXTERNAL-DEP (summary)
| Feature | Status | Evidence / note |
|---|---|---|
| Plans cards + prices ($0/$19/$49) | **REAL** | `tiers.ts:712`; rendered in `SettingsApp.tsx:543558` + `UpgradeModal.tsx:163165` |
| "Choose Pro/Teams" → checkout session | **REAL** | `checkout.ts:1356`, `adapter.ts:2667`, `useBilling.ts:6981` |
| Monthly/Annual toggle honored at checkout | **MUST-BUILD (small)** | backend ready (`index.ts:91100`); adapter drops `billingPeriod` (`adapter.ts:2667`) |
| Hosted Stripe Checkout (card form) | **REAL (redirect)** + **EXTERNAL-DEP** | needs `STRIPE_SECRET_KEY` + price-id envs (`index.ts:29`, `checkout.ts:30`) |
| Promo code | **REAL (on hosted page)** | `allow_promotion_codes:true` (`checkout.ts:44`) |
| Post-checkout sync → tier flip | **REAL** | `sync.ts:1890` (payment-gated), `useBilling.ts:104115` |
| Success "You're Pro" off synced tier | **DERIVABLE** | from `useBilling().tier` after sync |
| Success receipt rows (#/trial-end/then) | **MUST-GATE (fabrication)** | `/sync` returns only `{tier, customerId}` (`sync.ts:83`) |
| Manage: current plan header | **DERIVABLE** | `useBilling().tier`, `tierResolved` |
| Manage: payment method / invoices / cancel / next-charge | **MUST-GATE → Customer Portal** | no route exists; `portal.ts:1451` is the sanctioned surface; SettingsApp already does this (`SettingsApp.tsx:622634`) |
| Stripe Customer Portal | **REAL** + **EXTERNAL-DEP** | `portal.ts`, needs `stripe_customer_id` in config.json + Stripe account |
| Authenticated user email/name | **EXTERNAL-DEP (Clerk, screen 13)** | no account email in any contract; `userName` is memory-derived only (`home.ts:261`) |
| BYO-key vs metered positioning ("pay for scale" copy) | **EXTERNAL-DEP (founder decision)** | BUILD-PLAN §7 #5 / DESIGN_POV §4 — **decide before PR7** |
---
## 8. Recommended PR7 shape for Billing (so the builder doesn't fabricate)
1. **Plans (real):** port the 3-card grid + monthly/annual toggle from `billing.html`; bind prices to
`tiers.ts`; wire the toggle through a new `billingPeriod` arg on `createCheckoutSession`.
2. **Checkout (redirect):** "Choose Pro/Teams" → spinner → `window.open(session.url)`. Optional in-app
pre-summary with plan + list price ONLY. **No card fields, no fake tax/dates.**
3. **Success (synced):** themed "You're Pro" off the post-`?session_id=` synced tier. **Receipt block
omitted** (or shows only what `/sync` returns: tier). Trial line only from real trial fields.
4. **Manage (portal):** themed current-plan header (real tier) + one "Manage subscription via Stripe"
button → Customer Portal. **No in-app invoice table / card / cancel.**
5. **Graceful degradation:** every state must handle Stripe-not-configured (503 `STRIPE_NOT_CONFIGURED`,
`index.ts:5`) and unresolved tier (`tierResolved=false`, `useBilling.ts:16`) — already the SettingsApp pattern.

View File

@@ -0,0 +1,251 @@
# PR7 Recon · 05 — Clerk Integration (themed, reconciled with local-first)
> Scope: Auth screen 13 (`auth.html`) of the warm-Hive redesign. RECON ONLY — no product
> code touched. Every claim cites `file:line` verified this session (2026-06-18).
> Topic: how to integrate Clerk into THIS stack (React 19 + TS + **Vite SPA** `apps/web`,
> React Router 6.30, also bundled into a Tauri desktop binary), themed to the warm tokens,
> reconciled with "account is optional / local-first."
---
## TL;DR verdict (the highest-uncertainty recon)
1. **Stack reality:** `apps/web` is a **Vite SPA** (`vite@^5.4.19`, `react-router-dom@^6.30.1`,
declarative `<BrowserRouter>`/`<Routes>` — NO loaders, NO SSR). The correct Clerk package
is **`@clerk/clerk-react`** (skill name `@clerk/react`), env **`VITE_CLERK_PUBLISHABLE_KEY`**.
**NOT** `@clerk/react-router` (that is React-Router-v7 *framework* mode with
middleware+`rootAuthLoader`, which this app does not use).
2. **Prior art exists and is excellent:** `apps/www` (the Next.js landing) already has a **full,
themed Clerk integration** — `ClerkProvider` + `dark` baseTheme + Hive `appearance` map
(`apps/www/app/layout.tsx:6-93,171`), hosted `/sign-in` + `/sign-up` catch-all routes,
`/account`, `middleware.ts`. The server already verifies Clerk JWTs (`packages/server/src/plugins/auth.ts`).
So Clerk is **REAL** in the repo — just **absent from `apps/web`** (the SPA target for screen 13).
3. **ARCHITECTURE VERDICT (founder decision required):** **Option (b) — optional Clerk sign-in
that unlocks sync/Teams/billing; the local desktop stays fully accountless by default.**
This is the only option consistent with both the design copy ("An account is optional — Waggle
runs fully local without one", `SCREENS.md:273-274`) AND the verified backend (the desktop
sidecar is accountless: `wsSessionToken` loopback auth + `config.json` tier, `local/index.ts:2047-2052`,
`local/routes/settings.ts:309-331`). It also matches the **prior ratified decision** that Tauri
Clerk is a "Phase 2 fast-follow, NOT Day 0" (`2026-05-03…brief:225`).
4. **EXTERNAL-DEP the founder must provide:** a Clerk **publishable key** for the SPA
(`VITE_CLERK_PUBLISHABLE_KEY=pk_…`). Keys already exist for `apps/www`/server
(`pk_test_ZWxlZ2FudC1jYW1lbC04…` in `…brief:143`; secret **rotated 2026-05-12** per
`docs/launch/drafts/2026-05-12-apps-www-deployment-readiness.md:161`). The desktop default
path needs **no key** (accountless).
---
## 1. Stack verification (what `apps/web` actually is)
| Claim | Evidence |
|---|---|
| Vite SPA, not Next | `apps/web/package.json:7` `"dev":"vite"`, `:95` `"vite":"^5.4.19"`; entry `apps/web/src/main.tsx:18` `createRoot(...).render(<App/>)` |
| React 19 | `apps/web/package.json:59` `"react":"^19.2.0"`, `:61` react-dom 19.2 |
| React Router **6.30**, declarative | `apps/web/package.json:64` `"react-router-dom":"^6.30.1"`; `apps/web/src/App.tsx:2,60,62-102` `<BrowserRouter>`/`<Routes>` — NO `createBrowserRouter`, NO loaders |
| shadcn/ui installed | `apps/web/components.json` exists (verified); BUILD-PLAN §2 "shadcn/ui fully installed" |
| Tauri serves the SAME `apps/web` dist | `app/src-tauri/tauri.conf.json:7` `"frontendDist":"../../apps/web/dist"`, `:8` `devUrl http://localhost:8080` |
| `VITE_` env prefix already used | `apps/web/.env.example:11` `VITE_POSTHOG_KEY=…` |
| **No Clerk in `apps/web` today** | `grep '@clerk' apps/web/package.json` → none; `grep -rln 'ClerkProvider|SignIn|useSignIn' apps/web/src`**0 files**. Clean slate for screen 13. |
`@clerk/react` IS present under `node_modules/@clerk/react` but only as a **transitive** dep of
`@clerk/nextjs` (apps/www) — not a direct `apps/web` dependency. PR7 must add it explicitly.
---
## 2. Clerk in the repo today (prior art — REAL)
| Surface | What exists | Evidence |
|---|---|---|
| `apps/www` (Next.js landing) | `@clerk/nextjs@^7.3.0` + `@clerk/themes@^2.4.57`; `ClerkProvider` in `<body>` with `baseTheme:dark` + full Hive `appearance.variables`+`elements` map | `apps/www/package.json:15-16`; `apps/www/app/layout.tsx:6-7,35-93,171-173` |
| `apps/www` hosted auth | `<SignIn/>` at catch-all `/sign-in/[[...sign-in]]/page.tsx`, `/sign-up`, `/account` | `apps/www/app/sign-in/[[...sign-in]]/page.tsx:1,17-23` |
| `apps/www` middleware | `clerkMiddleware()` (all routes public, per-route `auth.protect()`) | `apps/www/middleware.ts:6,16` |
| Server JWT verify | `@clerk/fastify` `verifyToken` + `createClerkClient`; `authenticate` decorator; auto-provisions internal user from Clerk claims (`upsertFromClerk`) | `packages/server/src/plugins/auth.ts:3,21,31-48` |
| Server config | `clerkSecretKey`/`clerkPublishableKey` from env (empty string default = solo mode) | `packages/server/src/config.ts:6-7,26-27` |
| Env contract | `CLERK_SECRET_KEY` / `CLERK_PUBLISHABLE_KEY` documented across `.env.example:32-33`, `render.yaml:47-49`, `docker-compose.production.yml:8-9,35-36` |
| **Team-mode gate** | Clerk-dependent server behavior activates **only when `CLERK_SECRET_KEY` is set**; absent ⇒ "solo/desktop mode" | `packages/server/src/ws/gateway.ts:46-54`; `packages/server/tests/local/session-timeout.test.ts:166-179` |
**Themed-Clerk pattern is already solved once** (`apps/www/layout.tsx`). PR7's SPA work is to port
that appearance approach to `@clerk/clerk-react`, recolored to the **warm** tokens (apps/www uses the
**old cooler** Hive hex `#08090c`/`#e5a000`; apps/web is now warm `#14110b`/`#e9a52c`).
---
## 3. Local-first crux — why the desktop must stay accountless
The desktop sidecar (`packages/server/src/local/`) authenticates with a **machine-local loopback
token**, NOT a Clerk identity:
- `GET /api/auth/session-token` returns `server.agentState.wsSessionToken` — "auth-exempt but
same-origin gated… The Tauri webview reads this once on connect() and sends it as a Bearer"
(`local/index.ts:2043-2052`). This is a **device** token, not a **user**.
- Tier resolves from a local file, default **FREE**, with PATCH noting "will be replaced by Stripe
webhook" (`local/routes/settings.ts:309-318,322-331,360`). There is **no logged-in user identity
on the desktop today.**
- The SPA adapter already injects `Authorization: Bearer <token>` on every non-exempt request
(`apps/web/src/lib/adapter.ts:445`), bootstrapping the token in `connect()`
(`adapter.ts:254-260,298-308`) with a 401→refresh→retry leg (`adapter.ts:311-321,465`).
**This is the seam Clerk plugs into.** `useAuth().getToken()` from `@clerk/clerk-react` returns the
Clerk session JWT in exactly the `Bearer` shape the adapter + `packages/server/src/plugins/auth.ts`
already consume — so an *optional* Clerk sign-in can swap the device token for a user JWT **only when
the user opts into cloud/Teams**, leaving the accountless local path untouched.
---
## 4. ARCHITECTURE VERDICT — does Clerk fit "account is optional"?
**Recommend Option (b): optional Clerk sign-in that unlocks sync / Teams / billing; local desktop
stays accountless by default.**
| Option | Fit | Why |
|---|---|---|
| (a) Clerk only on SaaS cloud (`apps/www`), desktop never signs in | Partial | Already true today, but screen 13 lives in `apps/web` (the SPA the desktop loads). A pure-(a) reading means screen 13 is a **cloud-only** surface and the desktop shows no auth at all — contradicts having an Auth screen in the app shell. |
| **(b) Optional Clerk in the SPA; accountless is the default; sign-in unlocks sync/Teams/billing** | **Best** | Matches design copy ("account is optional", `SCREENS.md:273-274`), matches the accountless sidecar (`local/index.ts:2047-2052`), matches the prior "Tauri Clerk = Phase 2 fast-follow, NOT Day 0" decision (`…brief:225`), and matches the existing `useBilling` tier flow that already gates upgrade behind a server tier. Clerk renders only when `VITE_CLERK_PUBLISHABLE_KEY` is present; absent ⇒ screen 13 shows the local-first "you're running fully local" state with no fake identity. |
| (c) Full Clerk gate (must sign in to use the app) | **Reject** | Directly violates local-first + "account is optional"; breaks the accountless desktop boot (`/api/tier` defaults FREE with no user). Do not build. |
**Open sub-question for the founder (genuinely unknown):** in Option (b), does desktop Clerk sign-in
even run inside the **Tauri WebView**? Clerk's hosted OAuth/Account-Portal flow assumes a browser
redirect; in a desktop WebView the SSO redirect (Google/Apple) may need a system-browser + deep-link
loopback, or Clerk's custom-flow (`useSignIn`) with email OTP only. This is the same unknown the prior
brief deferred to "Phase 2." **Recommendation:** ship screen 13 as the **web/cloud-served** surface
first (browser context, where the apps/www pattern is proven), and treat in-WebView desktop sign-in as
an explicit follow-up requiring a Tauri deep-link/OAuth spike. Flag, don't guess.
---
## 5. The minimal, correct THEMED integration (for Option b)
### 5.1 Package + env (EXTERNAL-DEP)
- Add `@clerk/clerk-react` (current SDK, pairs with apps/www's `@clerk/nextjs` v7) + `@clerk/themes`.
- `VITE_CLERK_PUBLISHABLE_KEY=pk_…` (founder provides; reuse the existing `apps/www` instance key).
Vite SPA = **publishable key only**; the secret stays server-side (`CLERK_SECRET_KEY`, already wired).
### 5.2 ClerkProvider placement
- Wrap `<App/>` (or just the auth-aware subtree) in `apps/web/src/main.tsx` — same level as the
existing `createRoot(...).render(<App/>)` (`main.tsx:18`). `ClerkProvider` must sit **above**
`react-query`/router but the design only needs auth in the screen-13 route + the sidebar user row,
so it can wrap inside `<App/>` if a no-key fallback is desired.
- **No-key guard (local-first):** if `import.meta.env.VITE_CLERK_PUBLISHABLE_KEY` is undefined, render
children **without** ClerkProvider and show the accountless state — never crash, never fabricate a user.
### 5.3 Appearance → warm CSS tokens (the themed part)
- **shadcn theme first.** `apps/web/components.json` exists, so per `clerk-custom-ui` the correct first
step is `appearance={{ theme: shadcn }}` (`@clerk/themes` shadcn, current SDK). Clerk's shadcn theme
reads the shadcn HSL vars — which PR1 already repointed to warm values:
`--primary:38 81% 54% (#e9a52c)`, `--background:40 29% 6% (#14110b)`, `--ring:38 81% 54%`
(`apps/web/src/index.css:20,29,46`). So most theming is **automatic**.
- Thin override on top, mirroring apps/www's pattern but with warm hex:
`variables:{ colorPrimary:'#e9a52c', colorBackground:'#14110b', colorText:'#…', borderRadius:'8px'
(=--r-sm, index.css:176), fontFamily:'Hanken Grotesk, system-ui' }`.
- Light/dark: Clerk's default theme respects CSS `color-scheme`; `apps/web/src/index.css:185` sets
`color-scheme:dark` (+ a `[data-theme="light"]` block at `:192`). Theme stacking
`[shadcn, dark]` or `color-scheme`-driven both work; reconcile with the existing `ThemeProvider`.
- apps/www's `layout.tsx:31-34` carries a real gotcha to copy: **do NOT use `as const`** on the
appearance object (over-narrows Clerk's `Appearance` union and silently drops `baseTheme`).
### 5.4 Prebuilt vs custom-flow components (what the design needs)
Screen 13 (`SCREENS.md:269-278`) wants: split brand panel + form; **Sign in (Google/Apple SSO +
email/password)**, **Sign up**, **6-box OTP Verify (auto-advance, backspace nav)**, **SSO/enterprise**.
| Design element | Clerk mapping | Real/Build |
|---|---|---|
| Sign in / Sign up form | Prebuilt `<SignIn/>` / `<SignUp/>` (themed) — cheapest, proven in apps/www | REAL component, themed = small build |
| Google/Apple SSO | Clerk social connections (config in Clerk dashboard) — rendered by prebuilt comps automatically | EXTERNAL-DEP (OAuth creds in dashboard) |
| Email/password | Clerk default — prebuilt | REAL |
| **6-box OTP Verify** | This is Clerk's **email-code verification step**, which `<SignIn/>`/`<SignUp/>` render *as their own UI*. The design's bespoke 6-box auto-advance widget = **custom flow** via `useSignIn`/`useSignUp` (`clerk-custom-ui` core-3) **only if** they want the exact 6-box look; otherwise accept Clerk's built-in code input. | DERIVABLE (prebuilt) or MUST-BUILD (custom 6-box) — **founder choice** |
| SSO/SAML/SCIM → Teams/KVARK | Clerk **Organizations/Enterprise SSO** (`clerk-orgs`) — a "note → Teams/KVARK", not a live SAML flow in PR7 | note only; Orgs are a later/Teams concern |
| User row in sidebar (PR1 left `userName={null}`, BUILD-PLAN §9) | `<UserButton/>` (prebuilt popover) or `useUser()` to thread `HomeBriefing.userName` | DERIVABLE |
**Recommendation:** use **prebuilt `<SignIn/>`/`<SignUp/>` themed** for v1 (matches apps/www, lowest risk,
"Build with Clerk components themed to the tokens" is literally the design note, `SCREENS.md:277-278`).
Only drop to `useSignIn` custom flow if the founder insists on the pixel-exact 6-box OTP widget.
---
## 6. Billing half of PR7 (screen 14) — mostly already REAL (brief note; not my topic)
Flagged because PR7 bundles Auth+Billing and the honesty contract spans both:
- `apps/web/src/hooks/useBilling.ts` **already exists**`getTier`, `createCheckoutSession('PRO'|'TEAMS')`,
`createPortalSession`, `syncStripeCheckout(sessionId)`, post-redirect `?session_id=` auto-sync
(`useBilling.ts:34-115`). Adapter methods at `adapter.ts:2658-2676`.
- Server Stripe module exists: `packages/server/src/stripe/{checkout,portal,webhook,sync,index}.ts`.
- ⇒ Screen 14 is largely a **re-skin of the existing flow to warm tokens** + Stripe-hosted
Checkout/Customer-Portal (open in browser). **No card form is implemented in-app** today and the
design's "card 4242…, expiry/CVC" panel must **not** be hand-rolled — route to Stripe Checkout.
- **Blocked decision (DESIGN_POV §4, `DESIGN_POV.md:62-70`):** BYO-key vs Waggle-metered inference.
This reshapes Billing/Onboarding/Usage and "must be settled before Billing goes live"
(`DESIGN_POV.md:89`). Surface to founder before building screen 14.
---
## 7. FABRICATION RISKS (must be gated off — honesty contract)
PR7 is the **highest fabrication-risk PR** because Auth+Billing both render identity/money:
1. **Fake logged-in identity.** With no `VITE_CLERK_PUBLISHABLE_KEY`, the SPA must show the
**accountless** state, never a placeholder "signed-in" user, name, avatar, or email. The sidebar
user row already correctly renders "Account"/"W" when `userName={null}` (BUILD-PLAN §9) — keep that
honest; only populate from a **real** `useUser()` / `HomeBriefing.userName`.
2. **Fake invoices / receipts.** Screen 14 "invoices (Paid + PDF)" must come from Stripe
(Customer Portal), never a hardcoded invoice list. If no Stripe customer exists → empty/"manage in
portal", not invented rows.
3. **Fake payment method.** "VISA ···4242, Update" must reflect a real Stripe payment method or render
the empty/portal state. Do NOT ship a literal `···4242` as if it were the user's card.
4. **Fake usage / "Due today $19".** Trial-aware amounts must come from the real tier
(`useBilling.tierResolved`, `useBilling.ts:16-22` — it explicitly forbids presenting the `FREE`
default as fact) and Stripe price data, never a static string.
5. **Fake card-entry form.** The design shows a card form ("encrypted & secure, Powered by Stripe").
Collecting card data in-app is both a fabrication trap and a PCI risk — **use Stripe Checkout**,
render the form only as Stripe's hosted/embedded element.
6. **Fake SSO success.** SSO buttons must do a real Clerk redirect; never simulate "Signed in with
Google" without a Clerk session.
7. **Tier never silently FREE.** Already enforced by `useBilling.tierResolved` — keep any new auth/billing
surface honoring it (render "unresolved", not the FREE upgrade grid, until a real round-trip).
---
## 8. What's REAL vs DERIVABLE vs MUST-BUILD vs EXTERNAL-DEP
| Feature | Status | Note |
|---|---|---|
| Clerk JS SDK + JWT model | REAL | `@clerk/fastify` server verify (`plugins/auth.ts`), `@clerk/nextjs` themed (`apps/www/layout.tsx`) |
| Clerk in `apps/web` SPA | MUST-BUILD | add `@clerk/clerk-react` + `ClerkProvider` in `main.tsx`; **none today** |
| Themed appearance (warm tokens) | DERIVABLE | shadcn theme auto-reads warm shadcn vars (`index.css:20,29,46`) + thin `variables` override; pattern proven in `apps/www/layout.tsx:35-93` |
| Prebuilt `<SignIn/>`/`<SignUp/>`/`<UserButton/>` | REAL (Clerk) | design says "Build with Clerk components themed" (`SCREENS.md:277`) |
| Bespoke 6-box OTP widget | MUST-BUILD (optional) | only if not accepting Clerk's built-in code step; `useSignIn` custom flow |
| `getToken()` → existing Bearer adapter | DERIVABLE | adapter already sends `Authorization: Bearer` (`adapter.ts:445`); server already verifies (`plugins/auth.ts:31`) |
| Accountless local-first default | REAL | sidecar loopback token + FREE config (`local/index.ts:2047-2052`, `settings.ts:318`) |
| Billing flow (checkout/portal/sync/tier) | REAL | `useBilling.ts` + adapter + `server/src/stripe/*` all exist |
| In-app card form | EXTERNAL-DEP (Stripe-hosted) | do not hand-roll; Stripe Checkout |
| Google/Apple SSO, SAML/SCIM | EXTERNAL-DEP | OAuth creds + Clerk Orgs/Enterprise config in Clerk dashboard |
| `VITE_CLERK_PUBLISHABLE_KEY` for SPA | EXTERNAL-DEP | **founder must provide**; instance/keys already exist for apps/www/server |
| In-WebView desktop sign-in (Tauri) | UNKNOWN / spike | redirect/OAuth in WebView unproven; prior brief deferred to "Phase 2" (`…brief:225`) |
---
## 9. Decisions the founder must make before PR7 builds screen 13
1. **Architecture:** confirm Option **(b)** — optional Clerk, accountless default. (Recommended.)
Blast radius: defines `main.tsx` provider wrapping + the no-key fallback for the whole SPA.
2. **Surface scope:** does screen 13 ship as a **browser/cloud-served** surface first (proven), with
**in-WebView Tauri sign-in** as an explicit follow-up spike? (Recommended yes.)
3. **OTP UI:** accept Clerk's built-in verification step (cheap, prebuilt) vs MUST-BUILD the pixel-exact
6-box widget via `useSignIn`. (Recommend prebuilt for v1.)
4. **EXTERNAL-DEP:** provide `VITE_CLERK_PUBLISHABLE_KEY` (reuse existing instance) + confirm
Google/Apple social connections are enabled in the Clerk dashboard.
5. **Billing prerequisite (DESIGN_POV §4):** BYO-key vs Waggle-metered — settle before screen 14.
---
## 10. Honesty log / discrepancies surfaced
- The task framing assumed `clerk-react-router-patterns` might apply. It does **not** — that skill is
for React-Router **v7 framework mode** (SSR loaders + `clerkMiddleware`). This app is RR6 SPA ⇒
`@clerk/clerk-react` (`clerk-react-patterns`) is the correct skill. Documented to prevent a wrong build.
- apps/www's themed Clerk uses the **old cooler** Hive hex (`#08090c`/`#e5a000`,
`apps/www/layout.tsx:38-44`). Copy the *pattern*, not the *hex* — apps/web is warm
(`#14110b`/`#e9a52c`, `index.css:20,29`).
- `@clerk/react` is in `node_modules` (transitive via nextjs) but **not** an apps/web dep — do not
assume it's "already installed" for the SPA.
- Server `authenticate`/team-mode is gated on `CLERK_SECRET_KEY` presence; the desktop default (no key)
is the accountless path. PR7 must not assume Clerk is always on.

View File

@@ -0,0 +1,320 @@
# PR7 Recon — 06 · Routing & Surfaces (Auth /auth + Billing /billing)
> Topic: where `/auth` and `/billing` routes + entries live, reusing the PR1PR6 shell
> patterns. RECON ONLY — no product code touched. Every claim cites `file:line`.
> Verified against `origin/main @ 3764bc13` (PR1PR6 all shipped).
---
## TL;DR (the two registration shapes)
1. **`/auth` (screen 13) is SPECIAL — it is the ONE pre-shell, full-screen route.** Every
prior warm-Hive screen mounts INSIDE the `<AppShell>` layout route (`App.tsx:63-101`);
`/auth` must NOT. It belongs as a **sibling `<Route>` at the top level, outside the
`path="/"` AppShell element** — no Sidebar, no StatusBar, no ChatHost, no boot gate. It
is the only screen in the whole redesign that breaks the "child-of-AppShell" rule.
2. **`/billing` (screen 14) is NOT a new top-level route at all.** A complete Stripe billing
surface ALREADY EXISTS as **Settings → "Plan" tab** (`SettingsApp.tsx:511-655`), wired to
the real `useBilling` hook → real adapter Stripe calls → real server routes. PR7's billing
work is **(a) reskin that existing tab to the warm tokens + the SCREENS §14 4-state layout,
and (b) make it deep-linkable** (today `/settings` always opens on the Models tab and has
**no `?tab=` reader** — see the breadcrumb/deep-link gap below). A standalone themed
`/billing` route is OPTIONAL and only justified if the design wants the full-screen
Plans/Checkout/Success/Manage flow outside the Settings chrome.
---
## 1. The route table today (`apps/web/src/App.tsx`)
`App.tsx:52-110` — a SINGLE layout route owns everything:
```
<Route path="/" element={<AppShell />}>
<Route index element={<IndexRedirect />} />
…all 28 child routes (home, workspaces, memory, …, benchmarks, platform)…
<Route path="*" element={<NotFound />} /> // App.tsx:100
</Route>
```
- **Every** screen is a child of `<AppShell>` (`App.tsx:63`). There is currently **no
top-level route outside the shell** at all.
- Imports come from the `@/routes` barrel (`App.tsx:12-39`); the catch-all `*` must stay last
(`App.tsx:99` comment "ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL").
- PR6a added the two ⌘K-only static surfaces directly here: `benchmarks` (`App.tsx:97`) and
`platform` (`App.tsx:98`).
- **No `/auth`, `/billing`, `/login`, `/sign-in`, `/payment-success`, `/payment-cancelled`
routes exist** (verified by grep — zero hits in `App.tsx` / `routes/index.ts`).
---
## 2. The PR6 route-wrapper pattern (to copy)
A PR6 ⌘K-only surface is 3 small pieces. `BenchmarkRoute.tsx:1-11` is the template:
```tsx
/** PR6a route wrapper — `/benchmarks` → BenchmarkApp (static, ⌘K-only surface). */
import BenchmarkApp from '@/components/os/apps/BenchmarkApp';
import SurfaceBoundary from './SurfaceBoundary';
const BenchmarkRoute = () => (
<SurfaceBoundary appName="Benchmarks"><BenchmarkApp /></SurfaceBoundary>
);
export default BenchmarkRoute;
```
The three pieces for any in-shell surface:
1. **`routes/<Name>Route.tsx`** — thin wrapper that renders the App component inside
`<SurfaceBoundary appName="…">` (`SurfaceBoundary.tsx:10-17` wraps in `AppErrorBoundary`,
`onClose``navigate('/home')`).
2. **`routes/index.ts` barrel export** (`routes/index.ts:56-59` — PR6 lines).
3. **`<Route path="…" element={<…Route/>}/>` in `App.tsx`** under the AppShell layout
(`App.tsx:96-98` — the PR6a block).
> **PR7 BILLING (if a standalone `/billing` is wanted)** follows this exact 3-step pattern
> — a `BillingRoute` wrapper → barrel → child route under AppShell. **PR7 AUTH does NOT** —
> see §4: it is a sibling route, not an AppShell child, and `SurfaceBoundary`'s
> `onClose → /home` is wrong for a pre-login screen.
---
## 3. Command-catalog entries (`apps/web/src/lib/command-catalog.ts`)
The ⌘K catalog is built by `buildCommandCatalog()` (`command-catalog.ts:59-118`), grouped
**Jump to / Do / Power tools** (+ Pro "★ Pinned"). Entry shape `CatalogCommand`
(`command-catalog.ts:22-40`): `{ id, group, name, subtitle, icon, to?, action?, meta?,
minBillingRank?, pinned? }`. Each routes to a REAL `to:` or fires `action:'spawn'`.
**Billing is already represented in the catalog — but points at the wrong place:**
- `command-catalog.ts:75``Settings · "models · failover · permissions · plan"``to:"/settings"`
- `command-catalog.ts:76`**`"Upgrade to Pro"` · `"plans · billing · invoices"``to:"/settings"`**
Both land on `/settings` (which opens on the **Models** tab, NOT Plan — see §6 gap). PR7
should retarget these to whatever the billing entry point becomes (`/settings?tab=billing`
once a `?tab=` reader exists, or a new `/billing`).
**Auth has NO catalog entry and should NOT get one** — sign-in is a pre-login full-screen
route reached by redirect, not a ⌘K jump from inside the authenticated shell.
The catalog's tier-gating mechanism to reuse: `gate()` (`command-catalog.ts:99-100`) filters
on `minBillingRank` (FREE 0 / TRIAL 1 / PRO 2 / TEAMS 3 / ENT 4, `command-catalog.ts:36`); Pro
"★ Pinned" floats `pinned:true` items (`command-catalog.ts:104-111`). Catalog is consumed in
`AppShell.tsx:272-279` (`buildCommandCatalog` + `handleCatalogSelect`).
---
## 4. `/auth` — the pre-shell, full-screen registration (THE special case)
### Why it can't be an AppShell child
`<AppShell>` (`AppShell.tsx:436-466`) wraps everything in `<ShellProvider>`
`<ShellLayout>` (`AppShell.tsx:68`), which renders the BootScreen gate, the Sidebar
(`AppShell.tsx:314-323`), StatusBar (`AppShell.tsx:303-309`), ChatHost, all overlays, and the
onboarding takeover (`AppShell.tsx:285-296`). A sign-in screen must show **none of that**.
Mounting `/auth` as a child of `path="/"` would draw the whole authenticated chrome behind
the login form.
### Recommended shape (sibling route, outside the shell)
```tsx
<Routes>
<Route path="/auth" element={<AuthRoute />} /> {/* NEW — sibling, pre-shell */}
<Route path="/" element={<AppShell />}>
existing 28 children
</Route>
</Routes>
```
- `AuthRoute` is a **standalone full-screen component** — it MUST NOT use `SurfaceBoundary`
(its `onClose → navigate('/home')` assumes an authenticated home, `SurfaceBoundary.tsx:13`).
Wrap in a plain `AppErrorBoundary` if any, with `onClose → window.location.reload()` (the
same pattern `App.tsx:61` uses at the root).
- Theme still applies: `data-theme` is set pre-paint in `main.tsx` (per BUILD-PLAN §4) and
`<ThemeProvider>` wraps `<BrowserRouter>` at `App.tsx:53`, so `/auth` inherits warm tokens
even though it's outside AppShell. Good — no extra wiring needed for theming.
### Honesty gate (CRITICAL — auth is the #1 fabrication risk)
There is **no real user-identity auth in the product today.** What exists is a **same-origin
local sidecar session-token** (`adapter.ts:295-308` `fetchSessionToken`, refreshed at
`adapter.ts:316-331`; server route `local/index.ts:2047` `/api/auth/session-token`, auth-exempt
`security-middleware.ts:238`). That is a *dev/desktop bootstrap Bearer*, NOT a logged-in human.
The only "Clerk-gated" surface is the **CLOUD** server (`local/routes/agents.ts:12` comment:
"`/api/agents/*` CRUD exists only on the Clerk-gated CLOUD server"), which the local app does
not run.
So `/auth` is **EXTERNAL-DEP / MUST-BUILD**: real Clerk components need a `CLERK_PUBLISHABLE_KEY`
+ the `@clerk/clerk-react` provider (neither present — zero `@clerk` imports in `apps/web/src`).
**Until that wiring is real, the auth screen must NOT show a fake signed-in identity, a fake
name/avatar, or pretend a session exists.** The design's own framing helps here: SCREENS §13
(`SCREENS.md:271-278`) says "An account is optional — Waggle runs fully local without one" and
"Continue routes to Home." A PR7-honest auth screen can render the themed Clerk UI but, with no
key configured, must degrade to the local-first "continue without an account → Home" path
rather than inventing a logged-in user. (The sidebar user row already degrades to "Account" +
"W" avatar when `getIdentity()` returns no name — `AppShell.tsx:98-109`, PR1 LOW #2 — so the
"no real identity" state is already an accepted, non-fabricated UI.)
The `clerk-setup` / `clerk-react-patterns` / `clerk-billing` skills are available for the build PR.
---
## 5. `/billing` — reuse the EXISTING Settings "Plan" tab (do not rebuild from zero)
### What is already REAL (verified, fully wired)
- **Settings "Plan" tab** — `SettingsApp.tsx:41` (`{ id:'billing', label:'Plan', icon:DollarSign }`);
renders at `SettingsApp.tsx:511-655` (`activeTab === 'billing'`).
- **`useBilling` hook** — `hooks/useBilling.ts:24-124`: `startCheckout('PRO'|'TEAMS')`
(`useBilling.ts:69-81`), `openPortal()` (`useBilling.ts:84-96`), `syncAfterCheckout`
(`useBilling.ts:49-66`), auto-detects `?session_id=` on return (`useBilling.ts:104-115`).
P1b honesty already baked in: `tierResolved=false` until a real `getTier()` succeeds; the
default 'FREE' is NEVER shown as fact (`useBilling.ts:13-22, 38-43`; rendered unresolved
state at `SettingsApp.tsx:525-536`).
- **Adapter Stripe layer** — `adapter.ts:2658-2680`: `syncStripeCheckout`,
`createCheckoutSession`, `createPortalSession`.
- **Server Stripe routes (REAL)** — `packages/server/src/stripe/{checkout,portal,sync,webhook}.ts`.
`checkout.ts` returns **`503 STRIPE_NOT_CONFIGURED`** when no `STRIPE_SECRET_KEY`
(`checkout.ts:19-20`); success/cancel URLs are `/payment-success?session_id=…` and
`/payment-cancelled` (`checkout.ts:42-43`). Webhook handler + 4 price-var resolution shipped
per CLAUDE.md §10 (E-10).
- **CoverageCompassCard** value-prop card already renders above the tier card
(`SettingsApp.tsx:520`).
### What is a build GAP for the SCREENS §14 design (`SCREENS.md:282-295`)
| §14 design element | Status | Evidence |
|---|---|---|
| Plans state (3 cards, Pro popular) | DERIVABLE — upgrade buttons exist | `SettingsApp.tsx:579-619` |
| **Monthly / annual toggle (20%)** | **MUST-BUILD on FE** — server already accepts `billingPeriod` (`checkout.ts:14,29`) but adapter `createCheckoutSession(tier)` does **not pass it** (`adapter.ts:2667`); `useBilling.startCheckout` has no period arg (`useBilling.ts:69`) | grep: zero `billingPeriod`/`annual`/`monthly` in adapter+useBilling |
| Checkout state (card form) | EXTERNAL-DEP — use **Stripe Checkout** (hosted), per SCREENS §14 "Use Stripe Checkout/Customer Portal where possible" (`SCREENS.md:294`) | `checkout.ts` creates hosted `session.url` |
| Success state | **MUST-BUILD route**`success_url` points at `/payment-success` which **does not exist** as a route (grep: 0 hits in App.tsx); today only `?session_id=` is read by `useBilling.ts:104-115` on whatever page is mounted | `checkout.ts:42` vs App.tsx |
| Manage state (portal, invoices, PDF) | REAL — "Manage Subscription" → `openPortal()` → Stripe Customer Portal (`SettingsApp.tsx:621-635`, `useBilling.ts:84-96`). **Invoices/PDF are inside Stripe's portal, not our UI** — do NOT render fake invoice rows in-app. |
### Recommended registration for billing
- **Primary:** keep billing as the **Settings → Plan tab**, reskinned to warm tokens + the
§14 segmented 4-state layout. Add the **`/payment-success`** route (and optionally
`/payment-cancelled`) — these CAN be AppShell children using the PR6 wrapper pattern (the
user is back inside the app post-checkout), OR a tiny standalone confirmation. Wire ⌘K
`upgrade`/`settings` entries to deep-link the Plan tab.
- **Optional standalone `/billing`:** only if design wants the full Plans/Checkout/Success/Manage
flow outside Settings chrome. If so, follow the §2 PR6 wrapper pattern (AppShell child —
billing IS post-login, unlike auth). Reuse `useBilling` verbatim; do not duplicate Stripe calls.
### BYO-vs-metered (BLOCKER — founder decision before billing ships)
BUILD-PLAN §7 item 5 (`BUILD-PLAN.md:165-166`) and DESIGN_POV §4 (`DESIGN_POV.md:62-70`) flag
**who pays for inference (BYO-key vs Waggle-metered)** as the decision that "quietly reshapes
Billing, Onboarding, and Usage" and "should be settled before Billing goes live"
(`DESIGN_POV.md:89-90`). This is a **decision gate for PR7**, not a code question — the current
billing tab supports either ("supports either but commits to neither", `DESIGN_POV.md:70`).
---
## 6. The breadcrumb / `matchNavRoute` label gap (must NOT repeat for /billing)
### How the breadcrumb label is derived
`AppShell.tsx:224-229`:
```ts
const labelEntries = flattenAppEntries(getDockForTier('power', billingTier)); // 224
const activeRoute = matchNavRoute(location.pathname, labelEntries.map(e=>e.route)); // 225
const surfaceLabel = labelEntries.find(e => e.route === activeRoute)?.label ?? null; // 229
```
`surfaceLabel` is passed to `<StatusBar focusedWindowLabel={surfaceLabel}>`
(`AppShell.tsx:304`). `matchNavRoute` (`routes.ts:146-154`) is a longest-prefix match against
the **dock-tiers route table only**.
### The gap (this is the handoff "P3 / PR6 ⌘K-only routes show a fuzzy fallback label" note)
`dock-tiers.ts` `POWER_CONFIG` (`dock-tiers.ts:64-118`) does **NOT contain `/benchmarks` or
`/platform`** (confirmed by grep — zero hits in dock-tiers.ts). So for those routes
`matchNavRoute` returns `null``surfaceLabel = null` → **the StatusBar breadcrumb simply
does not render** (it is gated `{focusedWindowLabel && (…)}` at `StatusBar.tsx:84`). The
PR6 ⌘K-only surfaces therefore show **no breadcrumb at all** (not literally a wrong/fuzzy
string — the label is null and the breadcrumb chip is hidden). Either way the surface is
unlabeled in the status bar.
### Requirement for PR7
**`/billing` (and `/payment-success`, and conceptually `/auth`) MUST get a breadcrumb label
so they don't repeat the unlabeled-surface gap.** The label map (`labelEntries`) is sourced
ONLY from `getDockForTier(...)` — i.e. from `dock-tiers.ts`. Two options:
1. **Add a dock-tiers entry** (with `route` + `label`) for the billing surface so
`matchNavRoute` resolves it — same fix PR6 should have applied to benchmarks/platform. But
billing lives under `/settings` today (Settings already has a dock entry `dock-tiers.ts:108`,
label "Settings"), so a `?tab=billing` deep-link inherits the "Settings" breadcrumb already
— acceptable. A standalone `/billing` would need its own entry.
2. **`/auth` needs NO breadcrumb** — it renders outside AppShell (§4), so `AppShell.tsx`'s
StatusBar never mounts for it. The gap is irrelevant for auth by construction.
> Net: the breadcrumb gap is an AppShell-internal concern. `/auth` sidesteps it (no shell).
> `/billing` should either ride the existing "Settings" entry (deep-link path) or, if
> standalone, add a `dock-tiers.ts` route+label entry — do **not** ship it label-less.
### Deep-link gap that BLOCKS the "/settings?tab=billing" approach (verified)
`SettingsApp` initializes `activeTab` to **`'models'`** (`SettingsApp.tsx:53`) and has **NO
`?tab=` / `useSearchParams` reader** (grep: zero `tab=`/`useSearchParams`/`searchParams`/
`initialTab` in `SettingsApp.tsx`). So today `/settings?tab=billing` and even the existing
`APP_ROUTES.backup = '/settings?tab=backup'` (`routes.ts:52`) **silently open on Models, not the
requested tab.** For PR7 to deep-link billing from ⌘K / Upgrade buttons, SettingsApp needs a
small **`?tab=` initializer** (read once on mount, snap `activeTab`). This is a real, small
MUST-BUILD — without it the catalog "Upgrade to Pro" / UpgradeModal-fallback `navigate('/settings')`
(`AppShell.tsx:411-413, 422`) lands a user on Models, not Plan.
---
## 7. Existing upgrade entry points (where "Upgrade"/"Manage plan" link today)
| Surface | Action | Target | Evidence |
|---|---|---|---|
| Settings → Plan tab, FREE/TRIAL | Pro / Teams cards | `billing.startCheckout('PRO'\|'TEAMS')` → Stripe Checkout | `SettingsApp.tsx:583-598` |
| Settings → Plan tab, PRO | "Upgrade to Teams" | `startCheckout('TEAMS')` | `SettingsApp.tsx:604-619` |
| Settings → Plan tab, PRO/TEAMS | **"Manage Subscription"** | `billing.openPortal()` → Stripe Customer Portal | `SettingsApp.tsx:621-635` |
| Settings → Plan tab, non-ENT | Enterprise CTA | external link `https://www.kvark.ai` | `SettingsApp.tsx:638-652` |
| `UpgradeModal` overlay | `onUpgrade(tier)` | `adapter.createCheckoutSession(...)`, **fallback `navigate('/settings')`** | `AppShell.tsx:404-415` |
| `TrialExpiredModal` overlay | `onUpgrade(tier)` | `adapter.createCheckoutSession(...)`, fallback `navigate('/settings')` | `AppShell.tsx:417-424` |
| ⌘K catalog | "Upgrade to Pro" / "Settings" | `to:"/settings"` | `command-catalog.ts:75-76` |
| Sidebar user row | tier label "Trial · 9d" / "Pro" | (display only; row → Settings) | `AppShell.tsx:264-269, 321-322` |
All upgrade paths converge on Stripe checkout (real) or land on `/settings` (which mis-opens
on Models per §6). The "Manage plan" affordance is the existing **"Manage Subscription"**
button → Stripe Customer Portal. There is **no `KvarkNudge` component in `apps/web/src`**
(grep: 0 hits; CLAUDE.md §9 references it but it is not in the web app today — Enterprise CTA
is the inline kvark.ai link at `SettingsApp.tsx:644-651`).
---
## 8. Fabrication risks for PR7 (gate-off candidates — HONESTY CONTRACT)
1. **A logged-in identity that isn't real.** No Clerk/user-auth exists; the session-token is a
local dev Bearer. The auth screen must not render a fake signed-in user/name/avatar or
claim a session. Degrade to the design's local-first "continue without an account" path
when no `CLERK_PUBLISHABLE_KEY` is configured. (`adapter.ts:295-308`, `local/index.ts:2047`,
`AppShell.tsx:98-109` accepted "Account" fallback.)
2. **Fake invoices / PDFs.** SCREENS §14 lists "invoices (Paid + PDF)". Those live inside the
**Stripe Customer Portal**, not our UI. Do NOT render invented invoice rows or fake
"Download PDF" links in-app — route to `openPortal()` (`useBilling.ts:84-96`).
3. **Fake payment method ("VISA ···4242").** That `…4242` string in SCREENS §14 is design
filler. Real card-on-file data lives in Stripe's portal. Do not display a hardcoded masked
card in the Manage state.
4. **Fake "next charge" / usage / due-today numbers.** SCREENS §14 Checkout shows "Due today
$19 / won't be charged until …". Those must come from the real Stripe session, not be
string-literal'd. Prefer hosted Stripe Checkout (`checkout.ts:39-50`) which renders the real
amounts itself.
5. **Presenting tier as fact before it resolves.** Already guarded by `tierResolved`
(`useBilling.ts:13-22`); PR7 must preserve that — never show "Free plan" as fact while
unresolved (`SettingsApp.tsx:525-536`).
6. **A fake monthly/annual price.** The 20% annual toggle is a build gap (§5); when added it
must resolve through a real annual price var (server `priceIdForTier(tier, 'annual')`,
`checkout.ts:29`) — not a client-side `$19 × 0.8` cosmetic number that doesn't match what
Stripe charges.
---
## 9. File index (everything PR7 routing touches)
| Concern | File:line |
|---|---|
| Route table (add `/auth` sibling, optional `/billing` child, `/payment-success`) | `apps/web/src/App.tsx:52-110` |
| Route-wrapper pattern to copy | `apps/web/src/routes/BenchmarkRoute.tsx:1-11`; `routes/SurfaceBoundary.tsx:10-17` |
| Barrel | `apps/web/src/routes/index.ts:33-59` |
| AppId→URL table (`/auth`,`/billing` are NOT here yet; `backup` deep-link precedent) | `apps/web/src/lib/routes.ts:25-53, 70-80`; `matchNavRoute` `routes.ts:146-154` |
| ⌘K catalog (retarget upgrade entry) | `apps/web/src/lib/command-catalog.ts:62-97` |
| Shell breadcrumb derivation + label-source gap | `apps/web/src/components/os/AppShell.tsx:224-229, 304`; `StatusBar.tsx:84-93` |
| Dock label table (no benchmarks/platform/billing entries) | `apps/web/src/lib/dock-tiers.ts:64-118` |
| Existing Billing surface (REUSE) | `apps/web/src/components/os/apps/SettingsApp.tsx:36-47, 511-655` |
| Billing hook (REUSE) | `apps/web/src/hooks/useBilling.ts:24-124` |
| Adapter Stripe + session-token | `apps/web/src/lib/adapter.ts:2658-2680, 295-331` |
| Server Stripe routes | `packages/server/src/stripe/{checkout,portal,sync,webhook}.ts` (checkout `checkout.ts:13-58`) |
| Server local session-token (only "auth" today) | `packages/server/src/local/index.ts:2043-2047`; `security-middleware.ts:238` |
| SCREENS specs | `docs/design_handoff_waggle_app/SCREENS.md:269-295` |
| BYO-vs-metered decision gate | `docs/design_handoff_waggle_app/DESIGN_POV.md:62-70, 89-90`; `BUILD-PLAN.md:165-166` |

View File

@@ -0,0 +1,166 @@
# PR7 Recon — DESIGN_POV #4: BYO-key vs Waggle-metered (the inference-cost decision)
> RECON ONLY. No product code changed. Every claim below is grounded with `file:line`.
> Topic: make the founder's BYO-vs-metered choice **concrete with build cost**, grounded in
> what already shipped through PR1PR6. PR7 = screen 13 Auth (Clerk) + screen 14 Billing (Stripe)
> per `docs/redesign-warm-hive/BUILD-PLAN.md §6` (line 145).
---
## 0. TL;DR (the de-facto commitment)
**The codebase has already committed to Option A: BYO-key + flat SUBSCRIPTION tiers, with no inference metering.** This isn't a design intention — it is *shipped, wired, and tested* across three surfaces:
1. **BYO-key is the only inference-payment path that exists.** Onboarding's hard model gate and Settings → Models both mount the same `ModelGate` whose entire copy is *"Bring your own key — it's stored encrypted in your Vault and never leaves your machine."* (`apps/web/src/components/os/model-gate/ModelGate.tsx:186`). Waggle never holds an inference key or pays a provider on the user's behalf in any shipped path.
2. **Stripe billing is flat `mode: 'subscription'`** — both the desktop sidecar (`packages/server/src/stripe/checkout.ts:40`) and the `apps/www` cloud port (`apps/www/app/api/stripe/checkout/route.ts:170`). Two products (Pro $19, Teams $49/seat), monthly/annual, period-end renewal. **No `mode: 'payment'`, no usage records, no metered prices.**
3. **There is zero inference-metering plumbing.** No `createUsageRecord`, no `billing_meter`, no `reportUsage`, no credits/balance ledger anywhere in `packages/server/src` (verified by grep — only hit is a *comment* about not burning Anthropic credits in `packages/server/src/local/index.ts:1980`). The only "usage" surface is an **estimate-based read-only cost dashboard** (`/api/cost/summary`) with a **soft, advisory** daily-budget warning — never a hard cap, never tied to billing.
**Recommendation: ratify Option A.** PR7 Billing becomes a *theming* task over an already-working subscription flow (near-zero new backend). Option B (Waggle-metered) is a multi-month strategic pivot touching billing, onboarding, the inference path, quota enforcement, and a new credits surface — and it contradicts the local-first / "your key never leaves your machine" promise the product already makes to users in onboarding copy. The recon's job is to make this choice concrete; the decision is the founder's.
---
## 1. Evidence — what PR5 actually shipped for BYO-key (D1)
**`ModelGate.tsx` is the single shared "get a working model" component** — mounted in onboarding step 3 AND Settings → Models (`apps/web/src/components/os/model-gate/ModelGate.tsx:820` header doc).
- BYO-key cloud path: pick provider → paste key → **live-validate** (`adapter.testApiKey(..., { live: true })`, line 94) → write to Vault (`adapter.setProviderKey`, line 99).
- Explicit BYO framing in the UI: *"Bring your own key — it's stored encrypted in your Vault and never leaves your machine."* (line 186).
- Honesty contract already enforced: "verified" only after a live probe; a format-only pass says "looks valid (not live-verified)" (lines 1820, 247253).
- Local path (Ollama) is the other route to a working model — also zero cost to Waggle (lines 266304).
**Onboarding hard gate (D2):** `ModelGateStep.tsx` disables "Continue" until `useHasWorkingModel` is true (`apps/web/src/components/os/overlays/onboarding/ModelGateStep.tsx:49`), with copy *"Bring your own provider key … Nothing leaves your machine without your key."* (lines 2628) and one soft escape ("I'll do this later" → Home `NoModelBanner`, lines 917).
**Interpretation:** the user pays the provider directly. Waggle's margins are clean; it never carries inference cost. This is textbook **Option A (BYO-key)** from DESIGN_POV §4 (`docs/design_handoff_waggle_app/DESIGN_POV.md:6270`).
---
## 2. Evidence — Stripe tiers are flat SUBSCRIPTION, not metered
**`packages/shared/src/tiers.ts`** — canonical 5-tier system (TRIAL/FREE/PRO/TEAMS/ENTERPRISE), pricing in the header doc (lines 718): flat per-seat/per-month dollar amounts. `stripePriceId` is a single price per tier (lines 122, 143) — a **fixed recurring price**, not a metered/usage price.
**Desktop sidecar Stripe (`packages/server/src/stripe/`):**
- `checkout.ts:40``mode: 'subscription'`, `line_items: [{ price: priceId, quantity: 1 }]`. Quantity 1, fixed price. (Only PRO/TEAMS, line 25.)
- `webhook.ts:114158` — handles exactly **3** subscription lifecycle events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`. **No `invoice.created` / usage-record handling.** On cancel → tier drops to FREE (line 150).
- `index.ts:71100``tierFromPriceId` / `priceIdForTier` map fixed monthly/annual price IDs to tiers. Pure subscription mapping.
- `portal.ts:41``stripe.billingPortal.sessions.create` — defers payment-method / invoice / cancel management to **Stripe's hosted Customer Portal** (this is where invoices and payment methods legitimately come from, never invented locally).
**`apps/www` cloud port (already built — see §4):**
- `app/api/stripe/checkout/route.ts:170``mode: 'subscription'`, fixed `priceId` resolved by env or `lookup_key` `${tier}_${billing}` (lines 109119).
- `app/api/webhooks/stripe/route.ts:196205` — same 3 subscription events, mirrored to Clerk `publicMetadata`. No metering.
**Conclusion:** there is no metered/usage-based Stripe billing anywhere. The model is "pay a flat monthly fee for *capabilities* (workspaces, connectors, governance), not for *inference*." Inference is on the user's own key/quota.
---
## 3. Evidence — the current Usage/cost surface (what's shown today)
**There is NO `UsageApp.tsx`.** The de-facto Usage screen is `TelemetryApp.tsx` — titled **"Usage & cost"** in the UI (`apps/web/src/components/os/apps/TelemetryApp.tsx:159`). PR6b's "Usage/budget" screenshot (`docs/redesign-warm-hive/smoke-pr6b-20260618/04-usage-budget.png`) is this surface.
What it shows (all **read-only, estimate-based** — never a balance to draw down):
- Total tokens, **estimated** cost, by-model spend, by-workspace (TEAMS-gated) — from `GET /api/cost/summary` and `/api/cost/by-workspace` (`TelemetryApp.tsx:55100`).
- A **daily budget** the user can set, which produces a **soft warning at 80% / "exceeded"** status (`TelemetryApp.tsx:151`, `192195`) — purely advisory.
The backing route confirms the "estimate, not meter, not enforce" nature:
- `packages/server/src/local/routes/cost.ts:89`*"Data source: in-memory CostTracker … All cost values are **estimates** based on published model pricing."*
- Returns `estimatedCost` everywhere (lines 175, 181, 188).
- The daily-budget "exceeded" status (lines 162169) sets a **string status only** — nothing in the codebase blocks a request when exceeded. It's a dashboard, not a quota gate.
- Free for all tiers per a product decision (line 263, "P22 … usage/telemetry info is free for all tiers").
**Interpretation:** today's Usage tells the user *"here's roughly what your own provider key is costing you"* — a BYO-key courtesy readout. It is structurally NOT a metered-balance/credits surface.
---
## 4. Evidence — Auth (Clerk) status: NOT in the desktop app; FULLY built in `apps/www`
**Desktop `apps/web` has no Clerk and no real logged-in identity.** Grep for `Clerk|@clerk|SignIn|auth0` across `apps/web/src`**no files**. "Identity" today = a `tier` field in `config.json`, read fail-closed-to-FREE by `readTierFromDataDir` (`packages/server/src/middleware/assert-tier.ts:2132`). There is a *data-model placeholder*: `User.clerkId: string` exists in `packages/shared/src/types.ts:6`, but nothing populates it from a real Clerk session in the desktop path.
**`apps/www` (Next.js cloud/landing) already has a complete, themed Clerk + Stripe SaaS surface** — this is the direct reference (and possibly the literal home) for PR7's screens 13/14:
- `apps/www/app/sign-in/[[...sign-in]]/page.tsx``<SignIn />` Clerk component, themed via `<ClerkProvider>` (header doc line 15).
- `apps/www/app/sign-up/[[...sign-up]]/page.tsx` — sign-up (verified to exist via glob).
- `apps/www/middleware.ts:6,16``clerkMiddleware()` wired, matcher includes API routes.
- `apps/www/app/account/page.tsx` — account surface.
- `apps/www/app/api/stripe/checkout/route.ts` — lazy-create Stripe Customer → store id in Clerk `publicMetadata` (lines 81100); subscription checkout (line 170).
- `apps/www/app/api/webhooks/stripe/route.ts` — mirrors subscription state Stripe → Clerk metadata (3 events).
- `apps/www/app/_components/Pricing.tsx` — pricing cards.
**Net for PR7 Auth (screen 13):** in `apps/www`, Auth is REAL and only needs **theming to the warm-Hive tokens**. In the desktop `apps/web`, Auth is **MUST-BUILD if** the desktop must show a real logged-in identity (otherwise the design's own line "An account is optional — Waggle runs fully local without one" — `SCREENS.md:274` — means desktop can stay identity-light and route account/billing to the cloud `apps/www`). **This is itself a sub-decision the founder should confirm: does screen 13 live in `apps/www` only, or also in the desktop shell?**
---
## 5. The screen-14 fabrication risks (honesty contract carried from PR3PR6)
`SCREENS.md:282295` (screen 14 Billing) calls for four states. Three of them name fields that **must come from Stripe, never be invented**:
| Field in the design | Risk | Required gating |
|---|---|---|
| **Invoices (Paid + PDF)** (`SCREENS.md:292`) | Fabricating an invoice list / fake PDFs | Source ONLY from Stripe Customer Portal (`portal.ts` already does this) — do NOT render a local invoice list. If portal isn't reachable, show "Manage in Stripe" link, not a stub table. |
| **Payment method "VISA ···4242"** (`SCREENS.md:291`) | Hardcoding a fake card (the `4242` test card is literally in the spec text) | Never render a card brand/last4 the app doesn't have from Stripe. The portal owns this. The `4242…` in the design is a *mockup placeholder* — it must not ship as real-looking data. |
| **"Next charge" / billing cycle** (`SCREENS.md:291292`) | Inventing a renewal date | Only from Stripe subscription data via the portal. |
| **Checkout card form (email/card/expiry/CVC)** (`SCREENS.md:287289`) | Building a *fake* in-app card form that collects nothing real | Use **Stripe Checkout** (hosted) — the design itself says "Use Stripe Checkout/Customer Portal where possible" (`SCREENS.md:294`). The in-app form mock is illustrative; real PCI capture is Stripe's. |
| **Logged-in identity / avatar+name** (screen 13) | Showing a name/email for a session that isn't real | Bind to the real Clerk session (`apps/www`) or render the honest "no account / local-first" state (`SCREENS.md:274`). The desktop's `userName={null}` → "Account"/"W" pattern is the honest fallback (BUILD-PLAN §9 deferred note, line 191). |
**Plus a Usage-screen trap** if Option B is ever pursued: a credits/balance number, a "you've used X of Y tokens" quota bar, or a "$N remaining" figure would all be **fabricated** today (no ledger exists). The current estimate-only dashboard (§3) is the honest ceiling — do not dress it up as a metered balance.
---
## 6. The decision, made concrete
### Option A — Ratify BYO-key + flat subscription (RECOMMENDED, de-facto current state)
PR7 Billing themes the **existing** Stripe subscription flow; near-zero new backend. Exactly what's needed:
- **Auth (screen 13):**
- **Cloud (`apps/www`):** theme the existing `<SignIn/>`/`<SignUp/>` Clerk components + the brand split-panel to warm-Hive tokens. Add the local-first trust copy ("an account is optional"). ~UI-only.
- **Desktop (`apps/web`):** confirm whether it needs a real auth surface at all (§4 sub-decision). If "local-first, no account" stands, desktop screen 13 is a *deep-link to the cloud account page* + the honest no-account state — minimal build. If a real desktop session is wanted, that's the one genuine new piece (embed Clerk in the SPA / token bridge) — flag as a scoped add-on, not core to Option A.
- **Billing (screen 14):**
- **Plans state:** theme to tokens; data already exists (tiers.ts, `useBilling.startCheckout`). Add monthly/annual toggle UI (the 20% annual already exists as price IDs — `index.ts:91100`).
- **Checkout state:** redirect to **Stripe Checkout** (already wired both surfaces). The "in-app card form" from the design ships as a themed *intro/summary*, then hands off to Stripe — no PCI surface built.
- **Success state:** `useBilling` already syncs `?session_id=` post-checkout (`useBilling.ts:103115`). Theme the success ring/receipt; receipt link → Stripe.
- **Manage state:** `billing.openPortal()` already exists (`useBilling.ts:84`, `SettingsApp.tsx:625`) → Stripe Customer Portal owns invoices/payment-method/cancel. Theme the entry; do NOT build a local invoice/card UI (§5).
- **Usage:** leave the estimate-only "Usage & cost" dashboard as-is; optionally reskin to warm-Hive in the long tail. No metering.
- **Net new backend for Option A: essentially none.** Possibly: thread `STRIPE_PRICE_*_ANNUAL` into the desktop checkout UI's monthly/annual toggle (the resolver already supports it — `index.ts:91`), and (if desktop auth is wanted) a Clerk-session bridge. Otherwise pure theming + wiring existing routes to the new screens.
### Option B — Pivot to Waggle-metered (MAJOR ARC, strategic reversal)
Enumerated NEW plumbing (none of this exists today):
1. **Inference-cost metering per request** — a real, persisted, authoritative usage ledger (today's CostTracker is **in-memory + estimate-only**, `cost.ts:8`; it would need to become durable, exact, and per-user/account).
2. **Waggle holds the provider keys** — a managed model pool where Waggle's own key pays the provider. This **directly contradicts** shipped onboarding/Settings copy ("your key never leaves your machine") and the local-first promise — a product-positioning reversal, not just code. (`managedModelPool` capability exists as a *flag* in tiers.ts:97/130 but has no inference-path implementation behind it.)
3. **Usage caps / quota enforcement** — convert the *advisory* budget (`cost.ts:162169`, soft warning only) into a **hard gate** that blocks chat requests at the inference path when a balance/quota is exhausted. New enforcement point in the agent loop.
4. **Stripe metered/usage-based billing**`mode: 'payment'` top-ups or metered subscription items + `createUsageRecord`/billing-meter reporting. New webhook events (`invoice.created`, usage aggregation). None of the current 3-event handlers (`webhook.ts`) cover this.
5. **A credits/balance surface** — a new "$N remaining / buy more credits" screen + the ledger behind it. (None exists; building it without the ledger would be fabrication — §5.)
6. **Usage screen rework** — from "here's your own-key estimate" to "here's your metered balance, draw-down, and top-up" — a full rebuild of `TelemetryApp`.
7. **Margin/abuse controls** — rate limits, anti-abuse, cost-of-goods accounting that the BYO model never needed because Waggle carried no inference cost.
This is a multi-month arc that reshapes Billing, Onboarding (the model gate would invert — from "add your key" to "you're metered"), Usage, and the core inference path, and it takes on inference COGS + abuse risk that the current architecture deliberately avoids.
### Recommendation
**Ratify Option A.** Rationale: (1) the codebase has *already committed* to it end-to-end (BYO-key gate + flat subscription + no metering), so A is "finish what's shipped," (2) it keeps the local-first / "your key never leaves your machine" promise the product *already makes to users in onboarding*, (3) it keeps margins clean (no inference COGS), and (4) PR7 collapses to theming + wiring existing routes. Option B is a deliberate strategic pivot with real COGS, abuse surface, and a contradiction of live product copy — worth a separate, founder-led decision, **not** something PR7 should absorb. DESIGN_POV §4 said "the current design supports either but commits to neither" (`DESIGN_POV.md:70`); the *implementation* has since committed to A. PR7 should make that commitment explicit and themed.
---
## 7. Open sub-decisions for the founder (surfaced, not decided)
1. **Does screen 13 (Auth) live in `apps/www` only, or also in the desktop `apps/web`?** Desktop has no Clerk today; the design says accounts are optional. If desktop stays identity-light, PR7 desktop-Auth is a deep-link + honest no-account state (cheap). If a real desktop session is wanted, add a scoped Clerk-bridge task.
2. **Monthly/annual toggle on the desktop Billing tab** — the annual price resolver already exists (`index.ts:91`); the desktop UI currently only calls `startCheckout('PRO'|'TEAMS')` with default monthly (`SettingsApp.tsx:584`). Adding the toggle is small but is genuinely new desktop UI.
3. **Where does screen 14 Billing render?** The richest, already-real flow is in `apps/www` (Clerk-linked). The desktop SettingsApp Billing tab is a thinner subscription surface. PR7 could (a) theme both, or (b) make desktop Billing a deep-link to the cloud account page. Confirm.
---
## Appendix — files read for this recon (all `file:line` claims above traceable to these)
- `docs/design_handoff_waggle_app/DESIGN_POV.md` (§4, lines 6270)
- `docs/redesign-warm-hive/BUILD-PLAN.md` (§6 PR7 row line 145; §7 #5 line 165; §9 deferred note line 191)
- `docs/design_handoff_waggle_app/SCREENS.md` (screen 13 lines 269278; screen 14 lines 282295)
- `packages/shared/src/tiers.ts` (5 tiers, single stripePriceId per tier)
- `packages/shared/src/types.ts:6` (`User.clerkId` placeholder)
- `packages/server/src/stripe/{index,checkout,webhook,portal}.ts` (subscription-only)
- `packages/server/src/middleware/assert-tier.ts` (config.json tier, no real identity)
- `packages/server/src/local/routes/cost.ts` (estimate-only, advisory budget)
- `apps/web/src/components/os/model-gate/ModelGate.tsx` (BYO-key, shipped)
- `apps/web/src/components/os/overlays/onboarding/ModelGateStep.tsx` (hard gate)
- `apps/web/src/components/os/apps/TelemetryApp.tsx` ("Usage & cost" surface)
- `apps/web/src/hooks/useBilling.ts` (checkout/portal/sync, subscription)
- `apps/web/src/components/os/apps/SettingsApp.tsx` (Billing tab, upgrade/portal)
- `apps/www/{middleware.ts, app/sign-in/.../page.tsx, app/api/stripe/checkout/route.ts, app/api/webhooks/stripe/route.ts}` (Clerk + Stripe subscription, already built — PR7 reference)

View File

@@ -0,0 +1,44 @@
# Root-cause: "file-chooser storm" during the PR1 live smoke
**Verdict: dev/automation-only artifact. NOT a user-facing bug, NOT caused by PR1, no code fix warranted.**
## Symptom
During the PR1 Playwright smoke, navigating a **freshly-started** Vite dev server
(`localhost:8081/?skipOnboarding=true`) surfaced 5+ pending "file chooser" modal
states on load, blocking screenshots until each was cancelled
(`fileChooser.setFiles(undefined)`). 13 console errors also appeared.
## Investigation (systematic-debugging skill)
**Phase 1 — static analysis (exhaustive):**
- Enumerated every `<input type="file">` in `apps/web/src`: `BackupApp` (×2),
`SettingsApp`, `ChatApp`, `FilesApp`/`FileActions`, `ImportStep`, `HarvestTab`.
**All are hidden and clicked only via an explicit user-button `onClick`.** None
mount on `/home`.
- Enumerated every `.click()` call in `apps/web/src`: all are either `<a download>`
anchor exports (fire a `download` event, not a file chooser) or the user-bound
file-input clicks above. **No `useEffect`/mount-time file-chooser trigger exists.**
- `?skipOnboarding=true` bypass only writes `{completed:true, tier:'power'}` to
localStorage — no file interaction.
**Phase 3 — reproduction (decisive):**
- Re-ran the same URL against the **steady-state** Vite (`:8080`, deps already
optimized): landed cleanly on `/home` with **no file-chooser modal state and no
error flood**.
- DOM inspection on `/home`: **`input[type=file]` count = 0**, `activeElement = BODY`.
## Root cause
The artifact is tied to Vite's **first-run dependency re-optimization** on a cold dev
server, which forces a mid-render full-page reload while Playwright is driving the
page. Under that transient + automation, Playwright surfaced phantom file-chooser
pending-operations. It does not occur on a warm dev server, in the production build,
or in the Tauri desktop binary — and there is no application code path that opens a
file dialog without a user gesture (which real browsers block anyway).
The 13 console errors were a separate smoke-setup artifact: the `:8081` frontend
calling the founder's `:8080`/`:3333` sidecar with a mismatched session (cross-instance auth).
## Action
None on the product. For future smokes: warm the dev server (let dep optimization
finish) before driving Playwright, or smoke the production build / an already-running
dev server.

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

View File

@@ -0,0 +1,55 @@
# PR3 live smoke — 2026-06-16
Live browser smoke of the three warm-Hive PR3 screens against the running dev
env (sidecar :3333 + vite :8080, `?skipOnboarding=true` → tier:power), via
chrome-devtools. **Result: PASS — all three screens render correctly in dark +
light with 0 console errors.**
## Screens verified
| # | Screen | Theme | Evidence | Result |
|---|--------|-------|----------|--------|
| 01 | Home (Editorial) | dark | `01-home-dark.png` | ✅ |
| 02 | Workspace (Overview + tabs) | dark | `02-workspace-overview-dark.png` | ✅ |
| 03 | Chat (restyle) | dark | `03-chat-dark.png` | ✅ |
| 04 | Workspace (Overview) | light | `04-workspace-light.png` | ✅ |
## What rendered (real data, not mocks)
**Home (Phase A):** mono date row "TUESDAY, JUNE 16 · 12:23 PM" + live dot;
H1 = the real personalized greeting; honey "2 workspaces waiting for you."
(composed second line); overnight hero "Overnight, Waggle ran **8** automations."
(story composed from the real OvernightSummary count) + "8 automations completed"
run chip; "Pick up where you left off" cards (Continue + "1 to review" + kebab);
"Waggle suggests" with a composed sub-line; "Up next" schedules; ask bar (+ / ⌘K /
Send). Calm spine + "Pinned · power tools" (tier:power). Streak correctly hidden
(SHOW_STREAK gate — no fabricated streak).
**Workspace (Phase C):** breadcrumb "Home Writer demo — Anya"; 46px hex avatar;
H1; meta "9 memories · updated 4d ago"; Memory + Continue buttons + kebab; the
**6-tab bar with counts** (Overview · Chat 2 · Memory 9 · Artifacts · Files ·
Team 1), honey underline on the active tab; 2-col Overview — left = summary +
"What Waggle knows" fact rows (hex check tile + content + **real date**, no
fabricated `⬡ source`) + recent work; right = Status (Agent idle · Memories 9) +
Up next (blocked + next, "all →") + Team.
**Chat (Phase B):** restyled **ModelPill** ("Waggle picked the model — click to
override"); the new **bot meta line** "Waggle · Writer · claude-sonnet-4-6";
rich markdown (tables / blockquotes); the new composer ("Reply, or ask Waggle to
take the next step…" + mono "⏎ send · ⌘K" hint + honey send). The ActivityStream
+ work-canvas did not appear on this turn because it was a pure text response
(no step-blocks, no file-write) — correct behavior; they surface on agentic turns.
## Theme
Light mode verified on Workspace (`04`): warm-paper background, warm graphite
text, honey accents (active tab, Continue button, hex avatar, fact-row check
tiles) — a pure token swap, no light-specific code (PR1 architecture). 0 console
errors in either theme.
## Console
`list_console_messages(error)` returned **no messages** on Home, Workspace, and
Chat in both themes.
## Note
The pre-existing `LoginBriefing` Day-0 overlay renders on first Home load (not
part of PR3); dismissed via "Start Working". Not a redesign regression.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -0,0 +1,43 @@
# PR3.5 Memory-Trust — Live Smoke Evidence (2026-06-16)
Branch `feature/warm-hive-pr3` @ `876737e6`. Live browser smoke via chrome-devtools
MCP against the running dev env (FE vite hot-reload on the page origin; API base
`:3333`). Screens captured to this dir.
## Verdict: PASS (UI) — 0 console errors, dark + light
| # | Screenshot | What it proves |
|---|---|---|
| 01 | `01-trust-manage-dark.png` | Trust is the **default Memory view**; segmented Manage/Why; editorial hero; 4-stat bar with **honest "—" gating** on "High confidence & fresh" (no confidence in the set); All/Stale/Needs-confirm chips + disabled "Forgotten"; 3 real rows (M-47/M-45/M-1) with ConfidenceRing (—/unknown), `⬡ M-id · source: you · ● fresh`, M-1 = "aging — added 9w ago"; principle footer. |
| 02 | `02-trust-why-dark.png` | Why view: hero ("PROVENANCE · ACCOUNTABILITY" + quote H1), honest empty state ("Open a memory's ⬡ trace…"), Why-specific principle. |
| 04 | `04-trust-manage-light.png` | Same Manage view in **light** — pure token swap (PR1 architecture), honey accent + warm-light tokens intact. |
Also verified interactively (a11y snapshots):
- Cross-view flow: Manage row → edit drawer (Correct editor + EvidencePanel "source: user_stated" + Save/Why/Forget) → "Why is this here?" switches to the Why view and loads that memory's trace.
- a11y: ConfidenceRing `role=img` + aria-label; row buttons aria-labelled; segmented = toggle buttons (aria-pressed); "Forgotten" chip aria-disabled with rationale.
- Console: **0 errors / 0 warnings** across Manage, Why, drawer, dark + light.
## Constraint — live verification of the NEW sidecar routes deferred
The running `:3333` sidecar (PID 28776) **started 2026-06-12 22:53 — 4 days before
PR3.5** (first PR3.5 commit `c4794737`, 2026-06-16 14:13). `tsx` does not hot-reload,
so the running sidecar does **not** register PR3.5's new routes. Therefore, live:
- `GET /api/memory/:id/trace` → the Why view showed its **error** state ("Not found")
for M-1 instead of the honest `{trace:null}` empty state — **because the route is
unregistered on the June-12 sidecar, NOT a code bug.** With the route present, M-1
(no `trace_id`) returns `{trace:null}` → the "No trace is linked to M-1" empty state.
- `POST /api/memory/:id/confirm` and the **Part 1A** `frame.source` projection on the
`/api/workspaces/:id/context` route likewise need a fresh sidecar to see live.
These are all **unit-verified** (memory-center 25/25 incl. /confirm + /trace null/404
paths; workspaces.ts projection covered by server tsc) and the FE calls them correctly.
**To verify the new routes end-to-end: restart the sidecar from this working tree**, then
re-open `/memory` (Trust) and a memory's "Why is this here?".
## Notes
- Rows show `source: you` because the seeded/identity memories are genuinely
`user_stated`. The PR3.5-review write-fix stamps NEW agent memories `agent_inferred`,
so agent-derived rows will read "agent" once such memories exist.
- The two-nav-layer (MemoryCenterApp tab bar + Trust segmented control) and the
Hive-DS-vs-warm token seam are a known, documented integration trade (review MED,
deferred — broader restyle).

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

View File

@@ -0,0 +1,85 @@
# PR3.5 — Live route verification against a FRESH sidecar (2026-06-16)
**Why this exists:** the S3 handoff shipped PR3.5 but flagged one open item — the running
`:3333` dev sidecar was started 2026-06-12 (4 days pre-PR3.5), so `tsx` never loaded the
new routes. The committed UI smoke (`smoke-pr35-20260616/`) therefore ran the Memory-Trust
front door against a **stale** backend. This pass restarts the sidecar from the PR3.5 tree
and exercises the three new routes end-to-end (HTTP + FE), closing that gap.
**Verdict: PASS.** All three PR3.5 backend routes work live; the FE wires to them with **0
console errors/warnings**.
---
## Environment (verified, not assumed)
| Thing | State |
|---|---|
| Branch | `feature/warm-hive-pr3` @ `199bdbab` |
| Sidecar | fresh start from this tree — `WAGGLE_SKIP_LITELLM=1 tsx packages/server/src/local/start.ts` on `:3333`; `/health``database.healthy:true`, personal mind frameCount 3 |
| New-route presence proof | `GET /api/memory/999999/trace` (no auth) → **401 MISSING_TOKEN** (route exists) — NOT a Fastify "route not found" 404, i.e. the live sidecar has the PR3.5 code |
| Vite | running `:8080`, **serves this tree** (its served `MemoryTrustManage.tsx` module contains `ConfidenceRing` + `/confirm`) and proxies `/api` → my fresh `:3333` |
| Auth | bearer from `GET /api/auth/session-token` (loopback, no Origin needed) |
---
## 1A — `GET /api/workspaces/:id/context` projects `frame.source` ✅
The provenance ⬡ pill on the Workspace "What Waggle knows" rows is fed by the `source`
column the SELECTs now carry (workspaces.ts:394 / :411).
| Workspace | recentMemories | sources observed |
|---|---|---|
| `writer-demo-anya` | 8 | **`user_stated` AND `tool_verified`** (multi-source — proves not hardcoded) |
| `default-workspace` | 1 | `user_stated` |
| `new-hive` | 0 | (honest empty) |
`recentDecisions[].source` projected too. Writer-demo frames 14/15 carry `agent_inferred`
(the backend-review H-1 honesty fix — agent writes are no longer silently `user_stated`).
## 1.5 — `POST /api/memory/:id/confirm` + `GET /api/memory/:id/trace` ✅
**confirm** (HTTP + audit-trail proof):
- `POST /api/memory/47/confirm``status: active`, id 47. (404 on `999999`.)
- End-to-end write proof in `~/.waggle/audit.db`:
`event_type=memory_write ws=personal 2026-06-16 15:43:56 in={"frameId":47,"action":"confirm"} out={...,"status":"active"}`
→ route → `setMetadata``emitAuditEvent` → DB. Workspace-mind path also exercised
(writer-demo-anya frame 12, `mind:workspace`).
- The drawer for M-47 shows `updated 6/16/2026, 5:43:56 PM` — the exact `updatedAt` my
confirm stamped (live persistence proof).
**trace** (all three paths):
- `GET /api/memory/47/trace``{"trace":null}` (honest — manual frame, no `trace_id`).
- `GET /api/memory/999999/trace` → 404 `Memory not found`.
- `GET /api/memory/abc/trace` → 400 `Invalid memory id`.
- Populated `{trace:{...}}` requires a chat-written frame carrying `metadata.trace_id`
(pattern-write-back path) — none in the dev data; unit-covered (memory-center 25/25).
## UI smoke — Memory-Trust front door vs the FRESH sidecar ✅
`http://localhost:8080/memory`**Trust tab is the default Memory view**.
- **Manage** (`01-manage-fresh-sidecar.png`): honest stat bar — `3` memories · **`—`**
high-confidence (gated, not faked) · `0` stale · `0` awaiting confirm. 3 rows with the
live 3-segment provenance line `⬡ M-47 · source: you · ● fresh`; M-1 shows real freshness
`● aging — added 9w ago`. "Forgotten" filter honestly disabled (hard-delete, no recovery).
- **Detail drawer**: `source: user_stated`, `updated …5:43:56 PM` (my confirm write).
- **"Why is this here?"** → FE fired `GET /api/memory/47/trace?mind=personal`**200**
Why view rendered the no-fabrication empty state: *"No trace is linked to M-47. This
memory was added manually, imported, or written before traces were linked…"*
(`02-why-trace-null-fresh-sidecar.png`).
- All FE→backend calls 200 (`/api/memory?mind=personal&limit=500`, `/api/memory/stats?scope=all-minds`,
`/api/workspaces`, `/api/memory/47/trace`). **Console: 0 errors, 0 warnings** (whole session).
---
## Notes
- Audit log shows a **prior** live-verify ran at 15:18 (throwaway frames, since cleaned up)
— consistent with concurrent activity earlier in the day; this pass independently
reproduces PASS on a freshly-restarted sidecar.
- No code changed in this pass — verification only. Evidence is uncommitted (founder's call).
## Next (ship)
1. Mark **PR #17** ready. It's stacked on **PR #16** → merge/rebase **#16 → main first**,
then **rebase #17 onto main**.
2. Optionally commit this evidence dir alongside the prior `smoke-pr35-20260616/`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
# PR4 live smoke — 2026-06-17
Branch `feature/warm-hive-pr4` @ `d9123060`. Sidecar (tsx, transpile-only → runs the new route/decoration from source; `WAGGLE_SKIP_LITELLM=1`) on :3333 vs the founder's real `~/.waggle` (tier TRIAL, Marketplace DB loaded). Vite on :8080. Chrome-devtools MCP.
## Backend route (curl, real marketplace.db + connector registry)
`POST /api/marketplace/agent-search` with a minted session token:
- `{"need":"send a message to my team in slack"}`**7 candidates**, three-up:
- **connector** `Slack` — why `"matches: send, message, slack"``install:{mode:"active"}` (Slack is connected in this vault → honestly shown as active, no install).
- **skill** `slack-cursor``install:{mode:"store", extensionId:"pkg:84", type:"mcp", kind:"package", packageId:84}` (real id rejoin).
- **tool** `microsoft-teams-mcp-server``pkg:194616`.
- `{"need":""}`**HTTP 400** (validation).
- No-auth → **HTTP 401** (gated). `/health` → ok.
Confirms: the connector lane (registry keyword-match), the marketplace name→packageId rejoin, and the route wiring all work against live data.
## Frontend (chrome-devtools, /marketplace)
- Surface renders: header **Marketplace** + **" installed" count bar** (D1, store-derived); **4 shelves**`All` (pressed) / `Skills` / `Connectors` / `MCPs` (D2); search box; real marketplace packages (agent-skills, awesome-agent-skills, awesome-openclaw-skills, …).
- **"Browse only"** label on marketplace-pack cards (review LOW #6 fix — no blank action slot).
- Grid cards show **type-aware verbs** Add / Connect / Enable + honest "Available" badges (D3, §1 one-click).
- **AskBar** ("Ask Waggle") + example chips render at the top of the browse tab.
- Clicking the example chip "send a message to my team" → the **three-up suggestion box** rendered live:
- **Connector** slot — why `"matches: send, message, team"`**Connect** button.
- **Skill** slot — why `"name matches: team; content mentions: send, message"`**Enable** button.
- **Tool** slot — **Enable** button.
- **0 console errors / warnings** — on initial load AND after the agent-search (checked twice).
Evidence: `smoke-pr4-20260617-marketplace.png` (viewport), `smoke-pr4-20260617-after-search.txt` (a11y snapshot after search).
## Not exercised (deliberate)
- Did **not** click Add/Connect/Enable on a real package/connector — that mutates the founder's real `~/.waggle` (installs a real package / writes a real vault credential). Cross-surface count-sync on install is component-tested (`pr4-install-store`, `phase4b` "count ticks up"); the inline chat card is covered by `pr4-inline-capability`.
- The agent emitting `kind:'connector'/'mcp'` markers (so connector/mcp offers appear inline in chat) is a prompt-level follow-up; the parser + card support it.
## Gates at smoke time
tsc apps/web 0 · tsc packages/server 0 · full FE vitest 1052/1052 · server helpers 12/12 · adversarial review HIGH+MEDIUM fixed + regression-locked.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

View File

@@ -0,0 +1,60 @@
# Warm-Hive PR5 — Phase D Live Smoke (2026-06-17)
> **Verdict: PASS.** PR5 (Settings models-first/failover reskin + Onboarding 6-step + hard model gate) is live-verified end-to-end against a real sidecar. All five build gates green. **0 PR5-attributable console errors.** Ready for merge per the founder's call.
## Scope
Phase D of `docs/redesign-warm-hive/PR5-BUILD-PLAN.md §5` — the final phase. The adversarial review already ran (it produced the `80e44a16` HIGH fix: invalid Google/Gemini key was reported "verified"). This is the remaining **live browser smoke**.
- **Branch:** `feature/warm-hive-pr5` · **HEAD:** `80e44a16`
- **Stack:** Vite dev (`:8080`) → proxy → Fastify sidecar (`:3333`, `WAGGLE_SKIP_LITELLM=1`), driven via Chrome DevTools MCP.
- **Two sidecar states used:** (1) real `~/.waggle` vault (12 provider keys + 7 Ollama models present) for the reskin + happy-path; (2) a throwaway **clean-vault** sidecar (`HOME=temp`, empty vault, `OLLAMA_HOST` → dead endpoint → `useHasWorkingModel=false`) to live-test the gate's *blocking* purpose. Clean sidecar verified zero models: `providers_with_key=[]`, `ollamaInstalled:false, totalLocalModels:0`.
## Verification gates (re-run fresh at HEAD `80e44a16`)
| Gate | Result |
|---|---|
| `tsc -p apps/web/tsconfig.app.json` | **0 errors** |
| `tsc -p packages/server/tsconfig.json` | **0 errors** |
| FE PR5 vitest subset (model-gate, onboarding steps, tier-filter, settings-reskin) | **48/48 (8 files)** |
| server `llm-key-probe.test.ts` (D3 live probe) | **16/16** |
| eslint on PR5-changed TS/TSX | **0 errors** (22 benign "file ignored by pattern" warnings) |
## A. Settings → Models reskin (real vault) — PASS
Route `/settings` (`SettingsRoute``SettingsApp`). Screenshots `01`, `02`.
- **Models is the default/lead tab.** Rail = General · Models · **Plan** (Billing→Plan, **D8**) · Permissions · Team · Backup · **Enterprise** (kept separate, **D8**) · Advanced.
- **"≥1 working model" banner** present & green: *"You have a working model — you're ready to go."*
- **Shared `ModelGate` is the Models lead**: API-key tab (12 provider chips, all "key configured") + Local-model tab (*"Ollama detected — 7 models installed"* + pull-a-model input, **D12** synchronous pull) + BYO copy *"…stored encrypted in your Vault and never leaves your machine"* (**D1**).
- **Top-right `Show: Essential / Standard / Everything`** segmented control (**D6**).
- **D7 verified live:** at **Standard**, the **Advanced** *and* Enterprise tabs disappear from the rail (collapses to General·Models·Plan·Permissions·Team·Backup); at **Everything** they return. The same dial also gates the dock's "PINNED · POWER TOOLS" — confirming **D6**'s single global disclosure axis (not a second persistence key), as designed.
- **Model Pilot failover chain preserved** (Primary→Fallback→Budget Saver + $/$$/$$$ + daily-budget) — not rebuilt.
- **0 console errors.**
## B. Onboarding 6-step, completes with a model (real vault) — PASS
`/?forceWizard=true`. Screenshots `03`, `04`, `05`.
- **Re-key 5→6 nav is sound** (the plan's highest regression risk). Top progress bar spans 6 steps (`valuemax=5`, 0-indexed). Walked: **1 Welcome → 2 About-you → 3 Model gate → 4 Import → 5 Template → 6 First-task**, Back/Continue and progress dots tracked correctly.
- **Step 3 mounts the same shared `ModelGate`** (API-key/Local tabs, provider chips, banner) **+ both D2 controls** ("I'll do this later" + "Continue"). With keys present the gate is open (Continue enabled).
- **Step 5 Template = curated 6** (**D5**): Research Hub · Engineering · Sales Pipeline · Marketing & Content · Product Management · Blank Workspace.
- Selecting **Research Hub** fired the real `POST /api/workspaces`**201 Created**, mapped the **Researcher** persona (TEMPLATE_PERSONA), and advanced to First-task with the task box **pre-seeded from the template hint** ("Help me design a literature review on my topic").
- **"Let's go!" landed inside the new workspace** at `/workspaces/research-hub/chat` — Researcher persona, workspace-scoped memory/skills, first task seeded into the composer (not auto-sent — correct, no surprise LLM call).
- **Inner step counter** reads "Step N of 4" on the 4 middle config steps (Welcome=intro, First-task=finale have no counter). Verified intentional framing, *not* an off-by-one — but flagged for designer confirmation vs the 6-dot progress bar.
### Note — one non-PR5 console 409 (documented, not a blocker)
On completing onboarding, `POST /api/tier/start-trial` returned **409** → console error. Response body: `{"error":"TRIAL_ALREADY_STARTED", "trialStartedAt":"2026-06-11", "trialDaysRemaining":9}`. This install already started its trial, so the duplicate is correctly rejected. **Not a PR5 regression** (trial-start is pre-existing onboarding-complete logic untouched by PR5; returns 200 on a genuinely fresh install). Workspace creation (201) and `onboarding/complete` (200) both succeeded. *Pre-existing nit (out of PR5 scope):* the FE logs this expected idempotency-409 as `console.error` instead of treating `TRIAL_ALREADY_STARTED` as a no-op.
## C. Clean-vault gate BLOCK + invalid-key honesty — PASS
Clean-vault sidecar (zero working models). Screenshots `06`, `07`, `08`.
- **Hard gate blocks (D2):** at step 3 with no model, banner flips to *"No working model yet — add a provider key or a local model below."* and **Continue is `disabled`** (tooltip "Add a working model to continue"). Provider chips show no "key configured" state.
- **Invalid-key honesty (validates `80e44a16`):** selected Anthropic, pasted a syntactically-valid but bogus `sk-ant-…` key, clicked **Validate & save** → server-side live probe (`POST /api/settings/test-key``api.anthropic.com` 1-token ping) → alert **"Key was rejected by the provider."** Banner stays negative, **Continue stays disabled** — no fabricated "verified", bad key does not unblock the gate. (The 401 is server-side; never reaches the browser → no console error.)
- **"I'll do this later" escape (D2):** dismissed onboarding → `/home` with the persistent safety-net banner **"No model yet. Add a provider key or a local model so your agent can actually run."** + **"Set up a model"** CTA (the C1 Home safety net). Home greeted "Welcome, Tester" (name persisted from the clean-vault identity write).
- **0 console errors** across all three states.
- *Minor cosmetic (pre-existing, not PR5):* the header model pill still renders the config `DEFAULT_MODEL` string `claude-sonnet-4-6` even with no key; the "No model yet" banner is the authoritative signal.
## Screenshots
`01` settings-models-reskin · `02` settings-localmodel + Standard gating · `03` onboarding model-gate (open) · `04` onboarding first-task (seeded) · `05` landed in Research Hub workspace · `06` gate blocked (no model, Continue disabled) · `07` invalid key rejected · `08` Home "No model yet" banner.
## Decisions confirmed live
D1 BYO-key · D2 hard gate + "later"→Home+banner · D3 live key-validate (honest reject) · D4 6-step shape · D5 curated-6 templates + persona map · D6 single Show dial · D7 Advanced→Everything-only · D8 Billing→Plan + Enterprise separate · D9/D10/D11/D12 as planned.
## Open / follow-ups (none block merge)
1. FE swallow the expected `TRIAL_ALREADY_STARTED` 409 (pre-existing, out of PR5 scope).
2. Designer confirm "Step N of 4" inner counter vs the 6-step progress bar (intentional framing).
3. Header model pill shows config default even with no key (pre-existing cosmetic).

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

View File

@@ -0,0 +1,44 @@
# Warm-Hive PR6a — Build + Review + Live Smoke (2026-06-18)
> **Verdict: PASS.** PR6a (Benchmarks 17 + Platform 18 + NotFound reskin) is built, reviewed, and live-verified against a real sidecar. All gates green. Review found **0 HIGH, 2 MEDIUM (both fixed + re-verified), 0 LOW.** **0 PR6a-attributable console errors.** Ready for merge into the PR6 line.
## Scope (PR6 sub-PR a — first of the 3-way split)
Two net-new, pure-UI, static-data, **⌘K-only** surfaces + the 404 reskin. No backend. Branch `feature/warm-hive-pr6`.
- **17 Benchmarks** — capabilities matrix (11 rows × 6 cols) + Memory SOTA (LoCoMo bars). `BenchmarkApp.tsx` (+test).
- **18 Platform** — Desktop / Boot (static showcase) / Coming-next tabs. `PlatformApp.tsx` (+test).
- **404** — `pages/NotFound.tsx` reskin (D5; standalone route, not a Platform tab).
- **Wiring** (integrator-owned): `routes/{BenchmarkRoute,PlatformRoute}.tsx` + barrel, `App.tsx` (2 routes), `command-catalog.ts` (2 Power-tools ⌘K entries).
## Build method
2 parallel build agents on disjoint files (Benchmark vs Platform/NotFound) → main-agent wiring of the shared files (no parallel-write conflicts). Decisions D3/D5/D6/D21 applied.
## Gates (re-run after the MEDIUM fixes)
| Gate | Result |
|---|---|
| `tsc -p apps/web/tsconfig.app.json` | **0 errors** |
| FE vitest (BenchmarkApp · PlatformApp · p7-b3-command-center) | **11/11 (3 files)** |
| eslint (PR6a changed files) | **0 errors** |
## Adversarial review (4 dimensions, workflow `wf_2ce6b417`)
- **Design-fidelity + no-fabrication (Benchmark):** **PASS** — all 11 matrix rows + 66 marks match `benchmark.html` exactly; LoCoMo bars 87.66/81.95/78.05/62.47 + stats (+5.71, p<10⁻⁵, 92.75%, 100% 5883ms) verbatim; competitors honestly credited ● on deep terminal coding; "positioning view, not a lab benchmark" disclaimer + dated provenance present. No invented/rounded numbers.
- **Correctness / a11y / routing:** **PASS** — wrappers above catch-all; unique ⌘K ids + real routes + valid icons; tablists use role/aria-selected; no runtime/console hazards.
- **Warm-token (D21):** **PASS** — only warm semantic vars; the sole raw hex (`#1a1407` on-honey text, macOS traffic-light dots) are allowed OS-chrome exceptions per the design.
- **Design-fidelity (Platform):** **2 MEDIUM → fixed:**
1. Channel icon tints were hardcoded honey-wash → now per-channel per `platform.html:160-175` (Desktop honey · Browser **work-wash** · Messaging honey · Mobile **intel-wash**). Live-verified computed bg: honey `rgba(233,165,44,.1)` / work `rgba(122,166,214,.12)` / intel `rgba(177,150,221,.12)`.
2. Boot hex used Tailwind `animate-pulse` (opacity) + static shadow → now a `boot-hex-pulse` keyframe (added to `index.css`, matching the repo's keyframe convention) animating the box-shadow per `platform.html:57-58`, **with a `prefers-reduced-motion` fallback**. Live-verified `animationName: boot-hex-pulse`, 2.2s.
## Live smoke (real sidecar :3333, Vite :8080)
- **/benchmarks** — Capabilities matrix (11 rows, ●/◐/○, honey Waggle column, honest terminal-coding row, disclaimer) + Memory SOTA (4 bars + 3 stat chips + caveat + OSS link). Toggle works. **0 console errors.** (`01`, `06`)
- **/platform** — 3 tabs: Desktop (macOS↔Windows toggle + spec tiles), Boot (static showcase, pulsing hex), Coming-next (4 channel cards, per-channel tints). **0 console errors.** (`02`, `03`, `05`)
- **404** — `/this-cell-is-empty` → reskinned "This cell of the hive is empty." + back-to-Home + ⌘K hint. The one console.error is the **intentional 404 telemetry log** (pre-existing `NotFound` behavior), fires only on a real bad route. (`04`)
- **⌘K discovery** — typing "benchmark" surfaces the live "Benchmarks — capabilities · memory SOTA" entry (DOM-confirmed; cmdk items aren't exposed in the a11y tree).
## Decisions honored
D3 (inline static benchmark data, dated 2026-06-18, verbatim, no fabrication) · D5 (404 = standalone NotFound route, not a Platform tab) · D6 (Boot = static showcase, not wired to real AppShell boot) · D21 (warm tokens; OS-chrome hex exempt).
## Screenshots
`01` benchmark SOTA · `02` platform desktop · `03` platform coming-next (per-channel tints) · `04` 404 reskin · `05` platform boot (pulse) · `06` benchmark capabilities matrix.
(Committed: 0205 small; 01/06 are large full-page captures kept local.)
## Open / follow-ups (none block merge)
- None for PR6a. Next in the PR6 line: **PR6b** (reskin + feature-fill of 06·07·08·12·16) then **PR6c** (04·15).

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

View File

@@ -0,0 +1,45 @@
# Warm-Hive PR6b — Build + Review + Live Smoke (2026-06-18)
> **Verdict: PASS.** PR6b reskins + feature-fills 5 shipped apps (Launcher · Storage/Files · Power surfaces · Evolution · App-sextet) in place. Built by 5 parallel agents, adversarially reviewed, and live-smoked. Review: **0 real HIGH (the 7 "HIGH" were PASS confirmations of the no-fabrication/regression contract), 1 actionable design-fidelity fix**, both that + a live-smoke finding fixed + re-verified. Gates green. **0 PR6b-attributable console errors.** Ready for commit.
## Scope (PR6 sub-PR b — the bulk; reskin-in-place, no rebuild)
| Screen | Change | Files |
|---|---|---|
| **B1 Launcher (06)** | Variation B (memory-sharing explainer) behind an A/B toggle; Variation A unchanged | LauncherApp.tsx (+test) |
| **B2 Storage (07)** | Variation A ("Where it lives": on-disk tree + storage-type cards) via StorageAndFilesApp A/B wrapper; FilesApp **Source column** (D11) | StorageApp, StorageAndFilesApp (new, +test), FilesApp, FilesRoute |
| **B3 Power (08)** | Shared warm primitives + per-model spend bars + daily-budget (Usage), Vault Renew, color-stratified risk badges (Approvals). **D8: no route consolidation** | power/power-primitives (new, +test), TelemetryApp, VaultApp, ApprovalsApp, AutomationCenterApp |
| **B4 Evolution (12)** | "By skill" version-ladder over real run data; D13-gated provenance badge | memory/EvolutionTab.tsx (+test) |
| **B5 Sextet (16)** | Room two-col, Artifacts 3-col grid (per-kind tints), Profile "what Waggle knows" (D20), Agents swarm CTA; **Mission Control = CockpitApp** warm sweep | RoomApp (+test), ArtifactCenterApp, UserProfileApp (+test), AgentsApp, CockpitApp |
## Build method
5 parallel build agents on disjoint file-sets (surgical/preserve-behavior + no-fabrication mandates); main-agent integration of the shared sweep. The concurrent agents shared one working tree → transient cross-agent tsc "errors" during the run resolved clean at the end.
## Gates (after fixes)
| Gate | Result |
|---|---|
| `tsc -p apps/web/tsconfig.app.json` | **0 errors** |
| FE vitest (full) | **1130 passing** (2 PR5 settings tests are **load-flaky** — pass 3/3 in isolation in ~1s; they render the heavy SettingsApp and time out only under full-suite concurrency. Not PR6b-related.) |
| eslint (changed files) | **0 errors** |
## No-fabrication — all six traps defused at build time (review-confirmed)
- **B1** provenance chip prefixed **"example ·"** (not a real recall).
- **B2 (D11)** Source column = **"—"** — grounded: `FileEntry` (core schema) carries no creator field anywhere. memoryCount gated to "—" when absent (no fake "142 memories").
- **B3 (D9/D13-class)** per-model bars from real `/api/cost/summary.byModel` (absent → gated); budget from `/api/settings`; Vault "Renew" reuses the real upsert (no rotate endpoint exists — not invented); Approvals risk **derived** from tool name (no risk field in payload, not fabricated).
- **B4 (D13)** absolute quality % gated off (only `delta_accuracy` exists → honest +N.Npp); "GEPA · judged by 3 models" **never rendered** (no judge roster stored).
- **B5 (D20)** Profile facts only from real stored fields (empty → section hidden); Room participants from live SSE only (design's fake "Jonas P · invited" **omitted**); Artifact source = coarse real class (not invented "made by Claude Code").
## Two findings fixed (review + live smoke each caught one the other missed)
1. **Mission Control was edited on dead code (live smoke).** `/settings/mission-control` renders **`CockpitApp`** — `MissionControlApp.tsx` is explicitly KILLED (rendered nowhere). B5 had relabeled the dead component (invisible + a §3.3 dead-code edit). **Fix:** reverted the dead-file edit; applied the D21 warm-token sweep to the live `CockpitApp` (8 raw emerald/amber/green → `--healthy`/`--attention`). The "Services-lead tab relabel" doesn't apply — CockpitApp is a single-scroll dashboard, no fleet/team/activity tabs (recon mis-mapping, noted).
2. **Artifact icons lost their per-kind palette (review).** B5 flattened all card icons to uniform honey-wash; design §16 uses per-kind tints. **Fix:** added a `KIND_TINT` map (9 kinds → warm `work`/`healthy`/`intel`/`honey` washes — design-faithful *and* D21-compliant) + applied. tsc + full vitest green after.
## Live smoke (real sidecar :3333, Vite :8080) — 0 console errors on every surface
- **B1** `/launcher` — A/B toggle; Variation A (live detect/launch preserved) + Variation B (3-node flow + 3 cards + "example ·" provenance). (`01`)
- **B2** `/files` — A/B toggle; "Where it lives" (real tree: palindrome.py 222 B, dirs "—") + Files browser (storage-type tabs preserved + **Source column "—"**). (`02`,`03`)
- **B3** `/settings/usage` — Daily Budget editor + real Tool Usage breakdown; per-model bars gated off (no spend). (`04`)
- **B4** `/memory` Evolution — "By skill / By run" toggle (existing run-review preserved). (`05`)
- **B5** `/room` (empty state preserved, no fake participants); `/settings/profile` "What Waggle knows" facts from real data (`06`); `/artifacts` 3-col grid (data fetch timed out under sidecar load — env, not code; per-kind tints code-verified) (`07`); Mission Control = CockpitApp (warm).
## Open / not blocking
- `/artifacts` data fetch timed out (10s) under sidecar load during the smoke — environment, not PR6b; the error state + Retry render correctly. Per-kind tints verified in code + tsc/vitest.
- The 2 load-flaky PR5 settings tests (pass in isolation) — pre-existing harness flakiness, documented.
- Next: PR6c (04 All-workspaces + 15 Habit).

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

View File

@@ -0,0 +1,38 @@
# Warm-Hive PR6c — All-workspaces (04) + Habit (15) — Build + Review + Live Smoke (2026-06-18)
> **Verdict: PASS.** C1 (AllWorkspacesApp) is built, reviewed (PASS, 2 LOW fixed), and live-verified. C2 (Habit streak) is verified correctly gated — no change is the honest outcome (no real streak field exists; flipping would fabricate). Gates green. **0 console errors.** Final sub-PR of the PR6 line.
## Scope (PR6 sub-PR c — the data surfaces)
- **C1 — All workspaces (04):** new `AllWorkspacesApp` — the full workspace shelf (Home stays the day view; this is "every workspace"). Grid (D14, Table = disabled "soon" toggle), name search, storage-type filter pills, per-card `WorkspaceActionsMenu`, click→open, empty state (D16, reuses `CreateWorkspaceDialog`). Replaces the `/workspaces``/home` redirect (D15) + adds a ⌘K Jump entry.
- **C2 — Habit (15):** backend-blocked — verification only (see below).
## C1 build + wiring
- **New:** `apps/web/src/components/os/apps/AllWorkspacesApp.tsx` (+ test, 8/8), `apps/web/src/routes/WorkspacesRoute.tsx`.
- **Wired (main):** routes barrel; `App.tsx` — replaced `<Route path="workspaces" element={<Navigate to="/home"/>}>` with `<WorkspacesRoute/>` (and dropped the now-unused `Navigate` import); `command-catalog.ts` — "All workspaces · every workspace · grid" Jump entry (LayoutGrid icon), ⌘K-only (D15).
- Reuses `useShell()`'s single `useWorkspaces` instance (no duplicate fetch), `WorkspaceActionsMenu`, `CreateWorkspaceDialog`, warm primitives.
## Gates
| Gate | Result |
|---|---|
| `tsc -p apps/web/tsconfig.app.json` | **0 errors** |
| FE vitest (AllWorkspacesApp + p7-b3-command-center) | **green** (AllWorkspacesApp 8/8) |
| eslint (changed files) | **0 errors** |
## Adversarial review — PASS (0 HIGH/MEDIUM; 2 LOW fixed)
- **No-fabrication: clean.** Every stat gated on a real optional `Workspace` field — `memoryCount``—` when absent (never a digit), `sessionCount`/`lastActive`/`storageType` badge omitted when absent. The design's "N to review" pending chip is correctly **omitted** (no `pendingCount` field exists — showing it would fabricate).
- **Correctness / design-fidelity / warm-token / a11y: clean.**
- **LOW-1 fixed (honesty):** the green dot was labeled "agent live" but derived from `health === 'healthy'` (no real per-workspace agent-activity field exists) → **relabeled to "healthy"** (honest meaning), variable renamed `isHealthy`.
- **LOW-2 fixed (a11y):** filter pills container `role="group"`**`role="radiogroup"`** for the `aria-checked` radios.
## Live smoke (real sidecar :3333) — 0 console errors
- `/workspaces` **renders the shelf** (no longer redirects to Home): "Workspaces" heading + New-workspace, search, storage pills (All 4 / Virtual·Local·Team with live counts), "Table — soon" disabled (D14), 4 cards each with `WorkspaceActionsMenu` and **"—" for absent memoryCount (no fabrication)**. (`01`)
- **Search** "Research" → narrows to "1 WORKSPACE" (Research Hub). **Open** → navigates to `/workspaces/research-hub`. **⌘K** "All workspaces" present in the Jump group (Home → All workspaces → Chat), routes to `/workspaces`.
## C2 — Habit streak (15): verified correctly gated, no change (D17)
- `HomeCockpit.tsx`: `SHOW_STREAK = false`; the chip is `{SHOW_STREAK && <StreakChip/>}` with `TODO(backend): expose a real streak on HomeBriefing, then flip this to true`.
- `HomeBriefing` (types.ts) carries **no `streak` field** — the dependency is genuinely absent.
- Per D17, the honest state is to **leave it gated** (a hardcoded/invented streak violates the no-fabrication contract — the PR3 streak precedent). OvernightHero already correct. Depth/nudges deferred (D18). **No code change.**
## Open / not blocking
- Minor systemic cosmetic: the header breadcrumb label for the 3 ⌘K-only PR6 routes (benchmarks/platform/workspaces) shows a fuzzy `matchNavRoute` fallback (e.g. "Chat" on /workspaces) — these routes aren't in the nav `labelEntries`. Out of scope to fix here (shared AppShell label logic affecting all routes); documented for a polish follow-up.
- PR6 line is now complete (PR6a + PR6b + PR6c). Next per roadmap: PR7 (Auth/Billing), PR8 (landing).

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

@@ -0,0 +1,46 @@
# PR7a Billing — Live Smoke (2026-06-24)
Branch `feature/warm-hive-pr7a`. Live-driven via chrome-devtools against a freshly-started
dev stack: sidecar **:3333** (`tsx src/local/start.ts`, TRIAL tier, **Stripe NOT configured**
no `STRIPE_SECRET_KEY`) + vite **:8080** (proxies `/api` → 3333). Dark + light. **0 console errors throughout.**
The Stripe-unconfigured dev env is the *ideal* condition to prove the F8 honest state end-to-end.
## Verdict
**PASS.** Every adversarially-confirmed review fix verified against the running app. The smoke also
**caught a real regression the unit tests could not**: finding #4's glow fix (a Tailwind class swap)
still rendered `box-shadow: none` — a Tailwind v4 arbitrary-shadow gotcha. Fixed live to the proven
inline-style pattern and re-verified.
## What was verified (file:evidence)
| Finding / behavior | Expected (honest) | Live result | ✓ |
|---|---|---|---|
| **D12** `/settings?tab=billing` deep-link | snaps to Plan tab | Plan tab `aria-selected=true` | ✅ |
| **#7** §14 subhead | "Memory is free forever. You only pay for scale…" | renders (dark + light) | ✅ |
| **#6** TRIAL "Current" | trial ≠ purchasable plan → Solo NOT "Current"/"Your plan" | tier=TRIAL → Solo shows **"Included"**, no "Current" badge anywhere | ✅ |
| **#8** a11y | honest `aria-pressed` toggle buttons (not a false radiogroup) | Monthly/Annual are `button[aria-pressed]`; clicking flips pressed-state | ✅ |
| **#3 / F8** Stripe-not-configured | upgrade CTAs disabled **pre-click**, not a 503 after | probe `GET /api/stripe/status``configured:false` → Pro/Teams CTAs **"Unavailable" disabled** | ✅ |
| **#4** Pro "Most popular" glow | honey glow on the focal card | **was `none` even after class swap** → fixed to inline `boxShadow:var(--shadow-honey)` → renders `rgba(233,165,44,.25) 0 0 0 1px, rgba(233,165,44,.35) 0 8px 30px -10px` (dark + light) | ✅ (fixed in smoke) |
| Annual toggle (D8/F9 display) | price swaps to the real annual labels | Monthly $19/$49 → Annual **$15 / mo · billed yearly**, **$39 / seat · yearly** | ✅ |
| **F5** `/payment-success` no-fabrication | no completed checkout → honest, never a fake receipt | headline **"Nothing to confirm"**; no "You're Pro", **zero** receipt rows (no Trial-ends / Emailed / Receipt / $19.00 / VISA / ···4242) | ✅ |
| Console | 0 errors/warnings | clean on `/settings?tab=billing` (dark+light) and `/payment-success` | ✅ |
## The regression the smoke caught (#4 glow)
`shadow-[var(--shadow-honey)]` (Tailwind v4 arbitrary class) computed to `box-shadow: none` on the
Pro card — verified twice incl. a hard reload (`ignoreCache`). The sibling `border-[var(--honey-line)]`
from the *same* class string DID apply, so it's specific to the arbitrary-shadow-with-CSS-var utility,
not staleness. Injecting `style.boxShadow='var(--shadow-honey)'` resolved to the full glow, so the fix
is the **BenchmarkApp inline-style pattern** (`BenchmarkApp.tsx:367`). Re-verified: glow renders in
dark and light. (AskBar/ChatApp use `focus-within:shadow-[var(--shadow-honey)]` — unverified here,
flagged as possibly the same latent gotcha; out of PR7a scope.)
## Evidence
- `01-billing-plans-dark-annual.png` — dark, Annual selected: 3 cards, Pro glow, F8 "Unavailable" CTAs.
- `02-billing-plans-light.png` — light (warm paper `rgb(247,241,228)`): glow + tokens + F8 hold.
## Not exercised (no honest data source in this env)
- **Paid Success ("You're Pro") + Manage Portal launchpad** — needs a PRO/TEAMS tier + a real Stripe
customer; covered by unit tests (`pr7a-billing.test.tsx`: synced-tier confirmation, success-race guard).
- **Enabled checkout CTAs / hosted-Checkout redirect** — needs `STRIPE_SECRET_KEY` in the sidecar env;
the disabled F8 path is the honest state here and was the one under test.

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

View File

@@ -0,0 +1,73 @@
# PR7b Auth (screen 13) — Live Smoke (2026-06-24)
Branch `feature/warm-hive-pr7b`. Live-driven via chrome-devtools against the dev stack
(sidecar :3333 + vite :8080). Clerk shared instance **elegant-camel-8** (`pk_test_…`).
Dark + light. **0 console errors throughout.**
## Verdict
**PASS.** Both phases verified against the running app.
## B1 — pre-shell route + accountless state (no Clerk SDK)
| Behavior | Live result | ✓ |
|---|---|---|
| `/auth` renders OUTSIDE AppShell | no Primary nav present | ✅ |
| Honest local-first state | "you're running fully local" + "you don't need this to start" | ✅ |
| Brand panel verbatim trust copy | "An account is optional…" + "Your memory stays yours…" | ✅ |
| No fabricated identity | no demo email, no fake SSO button (F1/F10) | ✅ |
| Continue → /home | navigates into the fully-local app | ✅ (unit) |
Evidence: `01-auth-b1-accountless-dark.png`.
## B2 — optional Clerk (D2(b)), themed
With `VITE_CLERK_PUBLISHABLE_KEY` present, the key-gate flips `/auth` from the accountless
notice to the **real** prebuilt Clerk form, app-wide `WaggleClerkProvider` mounted.
| Behavior | Live result | ✓ |
|---|---|---|
| Key-gate (accountless → Clerk) | `clerkMounted=true`, accountless notice gone | ✅ |
| Real prebuilt `<SignIn/>` (D13) | `.cl-rootBox` mounted; email field present | ✅ |
| Social buttons = dashboard config (no fabrication) | **Google + GitHub** render; **Apple absent** (not enabled) — honest (F10) | ✅ |
| Warm theming (dark) | Clerk primary button `rgb(233,165,44)` = honey `#e9a52c` | ✅ |
| Warm theming (light, theme-reactive) | primary `rgb(181,125,18)` = light honey `#b57d12` (tracked `useTheme`) | ✅ |
| Sign in ↔ Sign up toggle | own toggle switches; §2b honey local-first note shows on Sign up | ✅ |
| Redirect after auth | `<SignIn forceRedirectUrl="/home">` | ✅ (unit + props) |
| Enterprise = sales CTA, not live SAML | "Talk to sales → kvark.ai"; no SAML field (F11/D15) | ✅ |
| Router-integrated provider | `routerPush/replace → useNavigate` (no flicker) | ✅ |
Evidence: `02-auth-b2-clerk-signin-dark.png`, `03-auth-b2-clerk-signin-light.png`.
## Honesty / no-fabrication
- Social buttons render **only what the dashboard enables** (prebuilt `<SignIn/>`), so the
Google+Apple→Google+GitHub design/instance mismatch resolves itself — enable Apple in the
dashboard and it appears with zero code change. No invented provider.
- No-key path (production builds without the key) → the accountless local-first state, never a
fake identity. The local sidecar still authorizes with its device token; Clerk is NOT the
local API authorizer (the `getToken()`→Bearer cloud-sync seam is left for a later opt-in).
## Not exercised (needs a real account / manual QA)
- A full sign-in/sign-up round-trip (Google/GitHub OAuth redirect, email-code verify) — needs
real credentials. The component renders + themes + redirects-on-success are wired; the live
OAuth round-trip (esp. inside a Tauri WebView, D4) is the documented follow-up spike.
- `<UserButton/>` / `useUser()` feeding the sidebar user row — deferred (BUILD-PLAN §9).
---
## Adversarial review + fixes (2026-06-24, commit 0e0dc48e)
4-dim review (correctness/Clerk · no-fabrication-of-identity · design-fidelity · security/local-first)
→ per-finding verify → synthesis. **3 confirmed: 1 CRITICAL, 1 MEDIUM, 1 LOW** — all fixed.
| # | Sev | Issue | Fix | Verified |
|---|-----|-------|-----|----------|
| 1 | **CRITICAL** | A malformed/placeholder `VITE_CLERK_PUBLISHABLE_KEY` (incl. the `.env.example` placeholder) blanked the WHOLE app at boot — ClerkProvider throws synchronously in render, and `WaggleClerkProvider` sits above `AppErrorBoundary` (uncatchable). Unit tests missed it (they mock ClerkProvider); the first smoke used a real key. | Shape-validate the key in `lib/clerk.ts` (prefix + base64→host-ending-`$`), **inlined** (the monorepo resolves multiple `@clerk/shared` majors). `.env.example` placeholder blanked. | **Live, real ClerkProvider:** with `pk_test_REPLACE_ME` the app **boots fully** (sidebar, routes to /home, /auth = accountless) — no blank, no error boundary. Valid key restored → Clerk form back. |
| 2 | MEDIUM | Brand-panel hide breakpoint was `lg` (1024px); design hides < 820px → split lost across 8201023px. | `min-[820px]` arbitrary variant (AuthBrandPanel + AuthScreen). | tsc + design intent |
| 3 | LOW | Wordmark `font-semibold`/18px vs design 700/19px. | `font-bold`/`text-[19px]`. | Live: weight 700, size 19px |
**No identity-fabrication issues** found by the review: sidebar stays "Account"/"W", social buttons
are dashboard-driven (prebuilt), no live SAML form, no demo identity, and the device-token adapter
path is untouched (Clerk is not the local API authorizer).
Post-fix gates: FE tsc 0 · pr7b-auth **9/9** (+3 key-shape-gate tests) · full FE **1156/1156** ·
prod build green · live smoke valid **+ invalid** key, dark+light, **0 console errors**.
**PR7b is review-clean and ready to merge.**

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

View File

@@ -0,0 +1,47 @@
# PR8 Live Smoke — Warm-Hive `apps/www` landing reskin (2026-06-24)
**Branch:** `feature/warm-hive-pr8` @ `e44c83e9` (+ this report)
**Server:** `next dev -p 3100` (fresh restart on clean `.next`; loaded `apps/www/.env.local`, Clerk `pk_test_` dev key present)
**Viewport:** 1440×900 (Chrome DevTools MCP)
## Result: PASS — warm-Hive identity rendered on all surfaces, 0 console errors
| Surface | URL | Console | Evidence |
|---|---|---|---|
| Landing (hero) | `/` | **0 errors** (1 expected Clerk dev-key *warning*) | `01-landing-hero.png` |
| Clerk sign-in | `/sign-in` | **0 errors** (1 expected Clerk dev-key *warning*) | `02-signin-clerk-warm.png` |
| Pricing | `/#pricing` | — | `03-pricing.png` |
> The only console message on each page is Clerk's benign "loaded with development keys" warning — expected with the `pk_test_` dev key; production uses live keys (EXTERNAL-DEP). **Zero errors.**
## What the screenshots confirm
**01 — Landing hero:** warm graphite background (was cool `#08090c`), honey `#e9a52c`
highlight on "We'll be the AI." + the eyebrow + Download CTA + the hive-viz hexagon and
its four provider nodes (claude·sonnet / gpt·5 / qwen·local / gemini·2.5); **Hanken Grotesk**
display type; JetBrains Mono in the viz chrome (`~/.waggle/hive · live`, `local · signed · 42ms`,
stat labels via the now-live `var(--mono)`); subtle warm honeycomb texture. No cool blue-grey.
**02 — Clerk sign-in:** the prebuilt `<SignIn/>` card is fully warm — warm graphite card +
border, honey `#e9a52c` **Continue** button with near-black text, honey "Sign up" link,
GitHub + Google social buttons (dashboard-driven; Apple off, matching the apps/web instance),
warm body text. Confirms the `layout.tsx` Clerk `appearance` hex remap.
**03 — Pricing:** honey "PRICING" eyebrow + "Honest pricing" highlight; three warm tier cards
(Solo $0 / **Pro $19/mo — honey-bordered "MOST POPULAR"** / Teams $49/seat/mo · 3-seat min);
honey checkmarks + "save 17%" annual pill; warm CTAs. **Prices/copy byte-identical to pre-PR8.**
## Honest-stats held (no-fabrication contract §5)
- Proof section still shows the **conservative** framing (67.8% "three rivals agree", 0.3-pt
convergence, 87.5% trio-strict single-hop) — **not** upgraded to the 87.66 SOTA headline.
- `proof.human_quote` still empty; tiers/prices unchanged; all section copy intact (verified
live in the accessibility snapshot + `git diff` shows `messages/en.json` unchanged).
## Not exercised (deliberate)
- Real OAuth round-trip (Google/GitHub redirect) + email-code verify — needs real creds (manual QA, carried from PR7b P1).
- Real Stripe checkout charge — hosted flow; live keys are EXTERNAL-DEP.
- Light theme — landing is intentionally dark-only (deferred, see PR8-BUILD-PLAN §8).
## Gates at smoke time
`tsc --noEmit` 0 · `next build` green (14/14 pages) · `next lint` clean · `vitest` 10/10 ·
comprehensive hex re-audit: **zero non-warm hex** under `apps/www/app`.