HomeZero · implementation plan · 2026-06-24
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.
prompt/why/note.#faf9f5, Fraunces serif display, Hanken sans, pine #2e7b68 accent; honey/amber/clay are verdict data only.bun + wrangler need ~/.bun/bin / /opt/homebrew/bin on PATH for npm run deploy.app/(app)/board/page.tsx — Slice A (strip money UI) + Slice B (render the read as the voice).app/(app)/app.css — Slice A/B (remove .footing/.meter/.kband/.kscore/.lpanel; add the read’s voice styles).convex/schema.ts — Slice B (learningPanels.items → a prose read).convex/board.ts — Slice B (setLearningPanel → setRead; cardsForUser returns read).convex/markets.ts — Slice C (new marketMetrics query for the billboard).flue/src/advisor/tools.ts + prompts.ts — Slice B (project_learning → project_read; TEND/DREAM step 5).app/(app)/home/page.tsx + home/home.css — Slice C (rebuild to §06).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.
board/page.tsx.footing block (the slider + tally), currently ~lines 686–717.band pill + the .meter/.meter-cap widget (~lines 530–548 region) and the kband on the price row..kscore block (~lines 560–565).affordBandNum, meterPct, homeZeroScore, BUDGET_CEIL, SCORE_BASE, SCORE_LABEL, the ceiling/setCeiling/ceil/initialCeiling state, tally, the budget derivation.money(l.price) as a quiet line (no band pill, no meter).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.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.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.
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"]),
setLearningPanel → setReadexport 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 };
},
});
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 }
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.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.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.
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.)
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.”read.test.tscallConvex 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.)project_learning is gone and project_read is registered (roster test, like the scout test did).board:setRead. · Tests: npx vitest run green; npx flue build --target cloudflare clean.project_read, TEND/DREAM compose prose, tests + build green.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.
LearningPanel component, the learning derivation, the LearnItem/learnWord bits, and the <LearningPanel/> render. Remove .lpanel* CSS.const read = (data as { read?: {summary:string;points:string[]} | null } | undefined)?.read ?? null;.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..vzpoints bullet list styled in the warm voice family (ink text, tight, the ✦ accent) — reuse the tokens, no new chrome./board as smoke-brain-1 (after a beat writes a read) shows the advisor’s prose paragraph + bullets where the chart was, no bars.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,
};
},
});
preview into a shared helper both queries call, so the market filter has one source of truth.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.Re-order and reshape /home to the locked §06 layout. Keep the awakening/roster machinery; replace the money-centric hero + foot-bar mock.
read.summary (from cardsForUser, already queried) as the greeting line; fall back to a warm default while the advisor is waking.listings.getBySlug/address); static fallback if absent. This replaces the current Chrome-extension grid tile.marketMetrics (useQuery): On the market · Typical asking · Median on market · Over the record · Honest deals. “Updated today · written by your market agent.”.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..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./home shows greeting → install hero → WhatsApp → billboard (real numbers) → read-led board preview → workbenches → roster, with no foot-bar/affordability chips./home matches the §06 order with the live billboard and no money-as-hero.setRead); 3 before 4 (frontend needs a real read to show); 5 before 6 (billboard needs the query).feature/read-led-cutover; per-task commits; final whole-branch review; one PR.learningPanels rows + trigger an advisor beat to author the first read; live-verify board + launchpad as smoke-brain-1.