HomeZero · implementation plan · 2026-06-24

Read-Led Cutover — the plan

Executes the read-led cutover spec. Six tasks across three slices: rip the money dashboard off the board, turn the advisor’s message into prose it authors, and rebuild the launchpad to §06. Run via subagent-driven-development — a fresh Opus implementer + review per task.

Goal. The board and launchpad stop showing money dashboards and charts. Cards lead with the verdict read; the advisor speaks in prose; the market agent’s numbers live in one billboard.

Architecture. Slice A — board cutover (frontend removal). Slice B — the advisor’s prose read (Convex field reshape → flue tool+prompt author it → board renders it as the advisor’s voice). Slice C — launchpad §06 (a Convex billboard-metrics query + a rebuilt /home).

Tech. Next 16 (OpenNext/Cloudflare, worker homezero) · Convex nautical-rabbit-780 · Flue beta.5 advisor agent (worker homezero-flue) · TypeScript · vitest.

Global constraints — every task inherits these:

File map

1 Board cutover — money off, cards read-led Slice A · frontend

modify app/(app)/board/page.tsx · modify app/(app)/app.css

Pure removal + simplification. No backend touched. Cards already lead with the verdict headline (the syn==="market" path renders l.verdict.headline); this strips everything money-as-hero around it.

Remove from board/page.tsx

The entire .footing block (the slider + tally), currently ~lines 686–717.
The per-card affordability render: the band pill + the .meter/.meter-cap widget (~lines 530–548 region) and the kband on the price row.
The per-card .kscore block (~lines 560–565).
Dead helpers + state: affordBandNum, meterPct, homeZeroScore, BUDGET_CEIL, SCORE_BASE, SCORE_LABEL, the ceiling/setCeiling/ceil/initialCeiling state, tally, the budget derivation.
Card price row keeps money(l.price) as a quiet line (no band pill, no meter).

Remove from app.css

.footing and its children (.frow .fk .fn .fh .ftally .slider .fscale); .meter/.meter-cap; .kband; .kscore and remove .footing/.lpanel from the .appmain.wide > width list as those blocks go.
Verification (no board unit tests exist): npx tsc --noEmit clean; npx eslint app/(app)/board/page.tsx shows no new errors vs main (the pre-existing Date.now() impure error is allowed); a dev-server screenshot of /board as smoke-brain-1 shows cards leading with the verdict and no slider/meter/score.
Done when: the board renders read-led cards with zero money UI; tsc + lint-parity clean; visual confirms.

2 Convex — reshape the panel into a prose read Slice B · convex

modify convex/schema.ts · modify convex/board.ts

Swap the structured items array for an advisor-authored prose read. Public-safe by construction — the only fields are buyer-facing prose the advisor chose to say.

schema.ts — learningPanels

// was: items: v.array(v.object({ label, confidence }))
learningPanels: defineTable({
  userId: v.string(),
  read: v.object({
    summary: v.string(),              // 1–2 sentences, the advisor's voice
    points: v.array(v.string()),      // up to ~3 short bullets (may be empty)
  }),
  updatedAt: v.number(),
}).index("by_user", ["userId"]),

board.ts — setLearningPanel → setRead

export const setRead = internalMutation({
  args: { userId: v.string(),
    summary: v.string(),
    points: v.optional(v.array(v.string())) },
  handler: async (ctx, { userId, summary, points }) => {
    const read = { summary: summary.slice(0, 600),
      points: (points ?? []).slice(0, 4).map((p) => p.slice(0, 160)) };
    const now = Date.now();
    const prev = await ctx.db.query("learningPanels")
      .withIndex("by_user", (q) => q.eq("userId", userId)).unique();
    if (prev) { await ctx.db.patch(prev._id, { read, updatedAt: now }); return { ok: true }; }
    await ctx.db.insert("learningPanels", { userId, read, updatedAt: now });
    return { ok: true };
  },
});

board.ts — cardsForUser returns the read

// replace the learningPanel items mapping (~lines 237–245)
const panel = await ctx.db.query("learningPanels")
  .withIndex("by_user", (q) => q.eq("userId", userId)).unique();
const read = panel?.read ?? null;   // { summary, points } | null
return { cards, read };
// and the empty-user early return becomes: { cards: [], read: null }
Migration: existing learningPanels rows have items, not read. Since the data is fully reproducible (the advisor re-authors it on its next beat) and these are test fixtures, the simplest path is to drop the stale rows: a one-off npx convex run clearing learningPanels after deploy, then trigger a beat. The plan’s Task-3 prompt change makes the advisor write the new shape.
Done when: schema deploys; setRead + cardsForUser return read; convex run round-trip writes+reads a {summary, points}. If a board/convex test harness exists, add a round-trip test; otherwise verify via convex run.

3 Flue — the advisor authors the prose read Slice B · agent

modify flue/src/advisor/tools.ts · modify flue/src/advisor/prompts.ts · test flue/test/advisor/read.test.ts

Replace the deterministic project_learning (which mapped yearnings → bars) with project_read — the model composes the prose and the tool persists it. The yearnings stay in the brain (still drive scout/dream); the advisor now narrates them.

tools.ts — project_read (replaces project_learning)

const project_read = defineTool({
  name: 'project_read',
  description:
    'Write your buyer-facing READ of this household — a short, warm note in your ' +
    'own voice about where you have them so far. `summary` = 1–2 sentences; ' +
    '`points` = up to 3 short bullets (optional). PUBLIC-SAFE: this is what the buyer ' +
    'sees on their board — speak as yourself, never paste your private yearning notes. ' +
    'Call this after you fold signals in, so the board reflects your current read.',
  input: v.object({ summary: v.string(),
    points: v.optional(v.array(v.string())) }),
  async run({ input: { summary, points } }) {
    await callConvex('board:setRead', { userId: ctx.userId, summary, points });
    return 'read updated';
  },
});

Remove project_learning, the now-unused learningLabel/CORE_KEY_LABELS/clamp01 helpers if nothing else uses them, and swap the tool in the returned array. (brain_read still exposes yearnings to the model so it has the material to compose from.)

prompts.ts — TEND step 5 + DREAM step 5

TEND (line ~50) and DREAM (line ~80): change call project_learning ... → “call project_read with a short, warm read of this buyer in your own voice — one or two sentences plus up to three short bullets — drawn from your yearnings and what the board now shows; public-safe, speak as yourself.”

Test — read.test.ts

Failing test: build the advisor tools with a recording callConvex stub; find project_read; call run({input:{summary:'You lean rural.', points:['Water matters.']}}); assert it called board:setRead with {userId, summary, points} exactly. (Mirror the existing tool tests’ DI pattern.)
Assert project_learning is gone and project_read is registered (roster test, like the scout test did).
Consumes: Task 2’s board:setRead. · Tests: npx vitest run green; npx flue build --target cloudflare clean.
Done when: the advisor has project_read, TEND/DREAM compose prose, tests + build green.

4 Board — render the read as the advisor’s voice Slice B · frontend

modify app/(app)/board/page.tsx · modify app/(app)/app.css

Delete the bar chart; render the prose read as the board’s single advisor voice (one voice, per the locked decision). The boardhead voice area shows read.summary as the voice line and read.points as a couple of bullets — no “advisor:” label, the quiet ✦ mark only.

Remove the LearningPanel component, the learning derivation, the LearnItem/learnWord bits, and the <LearningPanel/> render. Remove .lpanel* CSS.
Read the new field: const read = (data as { read?: {summary:string;points:string[]} | null } | undefined)?.read ?? null;
In the .boardhead voice area: if read, render read.summary in the .vznote voice line and read.points as a small bullet list beneath it (with the ✦ mark); else keep the existing default line. Drop the dependence on the legacy voiceNote take-note for this content.
CSS: a small .vzpoints bullet list styled in the warm voice family (ink text, tight, the ✦ accent) — reuse the tokens, no new chrome.
Verification: tsc + lint-parity clean; dev-server screenshot of /board as smoke-brain-1 (after a beat writes a read) shows the advisor’s prose paragraph + bullets where the chart was, no bars.
Done when: the chart is gone; the board shows the advisor’s prose read as one voice.

5 Convex — the Market Report billboard metrics Slice C · convex

modify convex/markets.ts

A query that returns the five billboard tiles for a market, computed live from listings + verdicts — reusing the market/active filter that preview already uses (lines ~113–152).

export const marketMetrics = query({
  args: { marketCell: v.optional(v.string()),
    where: v.optional(v.string()) },
  handler: async (ctx, args) => {
    // resolve the same active-in-market listing set `preview` builds
    const active = /* reuse preview's town/active filter */;
    const prices = active.map(l => l.price).sort((a,b)=>a-b);
    const doms = active.map(l => l.daysOnMarket).filter((d):d is number => d!=null).sort((a,b)=>a-b);
    const med = (xs:number[]) => xs.length ? xs[Math.floor((xs.length-1)/2)] : null;
    return {
      onMarket: active.length,
      typicalAsking: med(prices),                                  // $
      medianDom: med(doms),                                        // days
      overRecord: active.filter(l => l.verdict?.category === "overpriced").length,
      honestDeals: active.filter(l => l.verdict?.category === "good").length,
    };
  },
});
Reuse, don’t duplicate: factor the active-listings-for-market resolution out of preview into a shared helper both queries call, so the market filter has one source of truth.
Done when: convex run markets:marketMetrics '{"where":"Paonia, CO 81428"}' returns the five numbers from real listings. Add a test if a convex test harness exists; else verify via convex run.

6 Launchpad — rebuild to §06 Slice C · frontend

modify app/(app)/home/page.tsx · modify app/(app)/home/home.css

Re-order and reshape /home to the locked §06 layout. Keep the awakening/roster machinery; replace the money-centric hero + foot-bar mock.

The §06 order

Greeting — the advisor’s prose, no label. Reuse the board read.summary (from cardsForUser, already queried) as the greeting line; fall back to a warm default while the advisor is waking.
Install hero — claude.ai/downloads styling: dark Install button left; right, a Zillow+HomeZero preview of 318 3rd St, $615,000. Pull that listing from Convex (listings.getBySlug/address); static fallback if absent. This replaces the current Chrome-extension grid tile.
WhatsApp sub-card — a typeable number field + Connect (promote the current WhatsApp grid tile into a real sub-card; copy from the spec). Capture only; the send path is deferred.
Market Report billboard — five white tiles, big Fraunces numbers, centered, from Task 5’s marketMetrics (useQuery): On the market · Typical asking · Median on market · Over the record · Honest deals. “Updated today · written by your market agent.”
Board preview — read-led: remove the .foot-bar mock (page.tsx ~230–233) and the within/over affordability chips (~line 241, .chip .ct .w/.o); show verdict-led mini cards instead.
Workbenches — “Run your numbers,” “Compare homes,” “Your shortlist,” all marked Soon (the existing workbenches tile, made explicit).
Roster — keep the existing team roster (advisor · NFV market agent).

home.css

Remove .foot-bar/.track/.fill/.knob and .chip .ct .w/.o. Add: .install-hero (two-column, dark button), .wa-card (number field + Connect), .billboard (white tiles, Fraunces numbers) — all in the warm-white language.
Verification: tsc + lint-parity clean; dev-server screenshot of /home shows greeting → install hero → WhatsApp → billboard (real numbers) → read-led board preview → workbenches → roster, with no foot-bar/affordability chips.
Done when: /home matches the §06 order with the live billboard and no money-as-hero.

Sequencing & ship

Spec: docs.cells.md/2026-06-24-read-led-cutover. Execute via superpowers:subagent-driven-development (Opus implementer + spec/quality review per task, final whole-branch review). Frontend tasks verify by tsc + lint-parity + dev-server screenshot (no board/home unit tests exist in the repo).