HomeZero · Agent Model on Flue · Implementation Plan · 2026-06-23

Advisor Agent — 1A → 1D.

Bite-sized, test-first tasks that take the advisor from a write-less birth shell to a living agent: it writes the board, grows a brain, runs on rhythm, and greets the buyer each morning. Every task has exact paths and real code.

repo: homezero/ (own git flow) @flue beta.2 · agents 0.16.2 16 tasks · 4 milestones exec: subagent-driven-development

For agentic workers: REQUIRED SUB-SKILL — use superpowers:subagent-driven-development to implement this plan task-by-task (fresh implementer per task on Opus, task review between, broad review at the end). Source spec: advisor-agent-spec.

Goal: Make the advisor agent operational — author the buyer's board, keep a private brain + journal, run five scheduled beats, and send a daily WhatsApp morning briefing.

Architecture: One Cloudflare Durable Object per buyer (/agents/advisor/:userId) in homezero/flue. Capabilities are agent-level defineTools paired by convention to native Flue base skills (build-time SKILL.md source). The agent's whole mind lives in ONE per-buyer Turso "brain DB" = its filesystem (a files table: IDENTITY, journal, playbooks) + its brain (yearnings/metrics/params) + its session log — addressable by userId from any libSQL client. Flue's flue_* engine tables stay in the DO; Convex stays the shared, app-served projection. Rhythm comes from extend() over DO alarms.

Tech stack: Flue (@flue/runtime, @flue/cli) on Cloudflare Workers/DO · Cloudflare Agents SDK (agents) · Convex (nautical-rabbit-780) · Turso/libSQL · Valibot · Vitest + convex-test · Anthropic Opus 4.8.

Global Constraints — bind every task

  • Agent invariant — the brain DB. Each agent's complete mind is ONE addressable per-agent Turso db = its filesystem (a files table: IDENTITY.md, JOURNAL.md, skill PLAYBOOK.mds) + its brain (yearnings/metrics/params) + its session log (transcript mirror, for the dream). Addressed by the instance id (advisor = userId, market = north-fork-valley) and reachable from any libSQL client — the basis for SSH/TUI operability. Flue's own engine tables (flue_sessions, flue_session_entries, flue_runs, …) stay in the DO's SQLite — that's how the agent RUNS; the Turso brain is what it KNOWS.
  • Build repo: all code lands in /Users/pete/Projects/Zero/homezero/ (flue/ + convex/ + app/) — its OWN git repo. Branch off homezero's main (feature/advisor-loop), PR, squash-merge. NOT the specs meta-repo worktree.
  • @flue version: pin @flue/runtime ^1.0.0-beta.2 / @flue/cli ^1.0.0-beta.1. Tool shape is the LEGACY defineTool({ name, description, parameters: v.object({…}), async execute(args, signal){ return string } }) — the existing 4 advisor tools already match. DO NOT migrate to {input, run}.
  • Rhythm: attach via export const cloudflare = extend({ base: (Base) => class extends Base {…} }) from the agent module. Use onStart() + scheduleEvery(seconds, 'method') + schedule(when,'method'). NEVER call setAlarm directly (Flue owns alarm()).
  • Model: advisor = anthropic/claude-opus-4-8, thinkingLevel: 'medium' (unchanged). Every delegated implementer/reviewer subagent runs on Opus.
  • Convex from the DO: call internal* writers via deploy-key callConvex(fnPath, args) / callConvexQuery(fnPath, args), fnPath = "module:fn". Don't call buyer-facing public fns from the DO.
  • Turso / the brain — 1:1 per agent. The MARKET uses one Turso db per market (env TURSO_*). The ADVISOR uses a per-buyer Turso db named from the userId, provisioned at birth in a shared group — env TURSO_API_TOKEN (provision), TURSO_ORG, TURSO_ADVISOR_GROUP, TURSO_ADVISOR_GROUP_TOKEN (db auth); the URL is DERIVED from userId so there's no per-buyer secret. Store fns take c: Client so vitest injects createClient({ url: ':memory:' }); the Worker resolves @libsql/client/web via a lazy cloudflare:workers import.
  • Filesystem = Flue's NATIVE tools, ONE store: Turso (no buffer). Flue gives the model six built-in tools — read, write, edit, grep, glob, bash — that run against the agent's SessionEnv (pluggable via the sandbox field; the default is an EPHEMERAL in-memory fs that doesn't persist, so we replace it). We supply a custom SessionEnv backed DIRECTLY by the per-buyer Turso files table: readFile/writeFile/edit/readdir/stat/exists/rm are Turso queries, and exec answers grep/glob/find as Turso content/path queries. ONE filesystem — Turso IS the disk; no in-memory buffer, no hydrate/flush, no just-bash. Arbitrary scripting bash (sed/awk/run programs) is NOT supported (the advisor doesn't need it; the market can opt into a real shell later). Do NOT build bespoke fs tools — the agent uses read/write/edit/grep on JOURNAL.md / <skill>/PLAYBOOK.md directly.
  • Self-improvement mechanism: learned state = skill_params + a per-skill PLAYBOOK.md (a row in the Turso files table, e.g. board/PLAYBOOK.md) the agent reads with its native read/grep tools and the dream rewrites with edit. The static base SKILL.md stays a build-time source file registered via createAgent({ skills }) — we do NOT depend on Flue re-discovering a rewritten skill at runtime; all learned state lives in the brain DB.
  • Secrets live only in flue/.dev.vars (gitignored) + Worker secrets / Convex env. Never commit or echo: ADVISOR_TURSO_*, TURSO_*, CONVEX_DEPLOY_KEY, AUTH_PASSWORD, ANTHROPIC_API_KEY, WhatsApp creds.
  • Real data only on anything buyer-facing; no individual people's names (institutional names ok).
Grounding corrections folded in (2026-06-23). Verified in code: real target is homezero/flue (not the experiments/ sandbox); @flue beta.2 keeps the legacy tool shape (no migration); FlueFs has readdir + parent-creating writeFile; DO classes (FlueAdvisorAgent) are generated by flue build from flue.config.ts (target: 'cloudflare'); @flue is currently NOT installed in homezero/flue (Task 1 installs it). Self-improvement re-routed off unproven runtime skill-rediscovery (see Global Constraints).

Milestone 1A — It writes shippable

Outcome: a newly-onboarded buyer's board is populated by an agent-authored card with a real question, and zeroing a home wakes the advisor to brief it. Kills the empty-board bug. Ships on its own.

Task 1 — Install @flue + confirm the build is green foundational

Modify: homezero/flue/package.json (deps already declared; install) Run in: /Users/pete/Projects/Zero/homezero/flue
Produces: a working flue/node_modules/@flue at beta.2; confirmation that the legacy tool shape builds and the existing market+advisor vitest suite passes. Every later task assumes this.
Step 1. cd /Users/pete/Projects/Zero/homezero/flue && bun install. Then read the resolved version: cat node_modules/@flue/runtime/package.json | grep '"version"' — expect 1.0.0-beta.2. If it resolves a higher beta whose defineTool rejects parameters/execute (a ToolLegacyDefinitionError at build), pin exact "@flue/runtime": "1.0.0-beta.2" in package.json and re-install. The repo's tools MUST keep building unchanged.
Step 2. Build + typecheck: bunx flue build (uses flue.config.ts, target: 'cloudflare') and bunx tsc --noEmit. Expected: build emits FlueAdvisorAgent/FlueMarketAgent with no tool-shape errors.
Step 3. Run tests: bun run test (vitest). Expected: existing suite green (market store + advisor factory + convex tests). Record the pass count as the baseline.
Step 4. Commit on feature/advisor-loop: git commit -m "chore(flue): install @flue beta.2, confirm legacy tool shape builds".
No code change beyond the lockfile. This task exists because the build resolving an incompatible @flue version would silently break every following task — so we gate on it first.

Task 2 — Advisor board tools (the write hand) unblocks empty board

Modify: homezero/flue/src/advisor/tools.ts Test: homezero/flue/test/advisor/board-tools.test.ts
Consumes: callConvex(fnPath,args) from advisor/convexClient.ts; makeAdvisorTools(store,{userId}) closure pattern (existing). Convex board.authorHomeCard (internalMutation, args {userId, slug, body, ask?, stage?, authorCell?, placedBy?}, board.ts:340-384), board.pinAsk (board.ts:402-410), board.agentCards (internalQuery, {userId}, board.ts:263-295).
Produces: three tools added to makeAdvisorTools — author_home_card, pin_ask, read_board — all closure-bound to userId.
Step 1 (test first). In board-tools.test.ts, build the tool set with a fake callConvex spy injected, call author_home_card with {slug:'123-main', body:'…', ask:{prompt:'Town or land?', control:'this-or-that', options:['Town','Land']}}, and assert the spy was called with ('board:authorHomeCard', {userId:'u1', slug:'123-main', body:'…', ask:{…}, placedBy:{by:'advisor'}}). Mirror the existing advisor tool test style (inject the seam, assert the Convex call).
Step 2. Run it — FAIL (tool undefined).
Step 3 (implement). Add to makeAdvisorTools:
const author_home_card = defineTool({
  name: 'author_home_card',
  description: 'Author or update a home card on this buyer\'s board…',
  parameters: v.object({ slug: v.string(), body: v.string(),
    ask: v.optional(askSchema), stage: v.optional(v.string()) }),
  async execute({ slug, body, ask, stage }) {
    await callConvex('board:authorHomeCard',
      { userId, slug, body, ask, stage, placedBy: { by: 'advisor' } });
    return `card authored for ${slug}`;
  },
});
Add pin_ask (→ board:pinAsk with {cardId, ask}) and read_board (→ callConvexQuery('board:agentCards',{userId}), returns the cards JSON as a string). Define askSchema as the valibot mirror of boardCardAsk (prompt, control: v.picklist(['yn','this-or-that','chips','knob']), optional options, why).
Step 4. Run tests — PASS. Step 5. Commit: feat(advisor): board write tools (author_home_card, pin_ask, read_board).
These call the INTERNAL writers via deploy key. author_home_card throws if the slug has no listing — the react prompt only authors for a real zeroed home, so that's the intended guard.

Task 3 — The native "board" skill native Flue skill

Create: homezero/flue/src/advisor/skills/board/SKILL.md Modify: homezero/flue/src/agents/advisor.ts (register skill) Test: homezero/flue/test/advisor/skills.test.ts
Consumes: createAgent((ctx)=>({…, skills})) config field; the SKILL.md import declaration (@flue/runtime/types/skill-md.d.ts).
Produces: a registered, activatable board skill that teaches card authorship + the one-question rule, paired to the Task 2 tools.
Step 1. Write board/SKILL.md — frontmatter name: board, description: "Compose and place a home card on the buyer's board; ask exactly one good question."; body = the playbook: when to use author_home_card, how to write a card body from the home + the buyer's lens, the rule "exactly one question, drawn from the strongest open yearning," the ask.control options, and "never overwhelm — one card, one question."
Step 2 (test). In skills.test.ts, import the agent factory and assert its config skills array contains an entry with name === 'board'. (Skill registration is config — assert on the returned config object, the same way the existing factory-shape test asserts model/tools.)
Step 3. Run — FAIL. Step 4 (implement). In advisor.ts:
import boardSkill from '../advisor/skills/board/SKILL.md';
// …inside createAgent return:
skills: [boardSkill],
Confirm tsconfig/flue build resolves the .md import (the runtime ships the module declaration; if tsc complains, add /// <reference types="@flue/runtime/skill-md" /> or the project's *.md ambient decl).
Step 5. Run — PASS. Step 6. Commit: feat(advisor): native board skill (SKILL.md) registered on the agent.
This is a real, native Flue skill: listed in the system prompt by name+description, loaded on demand via the runtime's activate_skill tool. The learned/self-tuning layer comes later (Task 14); this is the static base playbook.

Task 4 — The advisor zero-doorbell (wake on a zero)

Create: homezero/convex/advisorDoorbell.ts Modify: homezero/convex/stars.ts:92-109 (the two if(created) branches) Test: homezero/convex/advisorDoorbell.test.ts
Consumes: the marketDoorbell.ringMarket pattern (marketDoorbell.ts:13-36) and the ctx.scheduler.runAfter(0, internal.marketDoorbell.ringMarket, {}) call sites in stars.ts.
Produces: internal.advisorDoorbell.ringAdvisorZero({ userId, slug }) — an internalAction POSTing to /agents/advisor/${userId} with { message: JSON.stringify({ kind:'zero', slug }) }; fired alongside the market ring, scoped to the zeroing buyer.
Step 1 (test). In advisorDoorbell.test.ts, stub ADVISOR_WORKER_URL + AUTH_PASSWORD and a fetch spy; run ringAdvisorZero({userId:'u1', slug:'123-main'}); assert the POST URL = ${url}/agents/advisor/u1, the Bearer header, and the body = {message: JSON.stringify({kind:'zero', slug:'123-main'})}. Mirror the market doorbell test.
Step 2. Run — FAIL. Step 3 (implement). Copy marketDoorbell.ts to advisorDoorbell.ts; change args to { userId: v.string(), slug: v.string() }, the URL to /agents/advisor/${userId}, and the body kind to { kind:'zero', slug }. Keep the env-unset skip guard.
Step 4. In stars.ts, at BOTH if (created) branches that ring the market, add (scoped to the buyer's userId — read it from the star/intake context already present in the handler):
await ctx.scheduler.runAfter(0, internal.advisorDoorbell.ringAdvisorZero,
  { userId, slug });
Verify userId + slug are in scope at each site (the handler resolves the buyer + the listing slug; if slug isn't already derived, pull it from the saved/pending row).
Step 5. Run tests — PASS (advisorDoorbell + stars). Step 6. Commit: feat(convex): advisor zero-doorbell fired on zero alongside the market ring.

Task 5 — The react-beat prompt (brief + author + one question)

Modify: homezero/flue/src/advisor/prompts.ts (ADVISOR_INSTRUCTIONS) Test: homezero/flue/test/advisor/prompts.test.ts
Consumes: the configured-chat branch of ADVISOR_INSTRUCTIONS (prompts.ts) and the message-kind convention ({kind:'zero', slug} arrives inside message).
Produces: instructions that, on a kind:'zero' wake, activate the board skill, brief the home through the buyer's lens, and author exactly one card with one question.
Step 1 (test). Assert ADVISOR_INSTRUCTIONS contains the react contract: the string mentions parsing kind, that on 'zero' it must call author_home_card once with a single ask, and that it activates the board skill first. (A content assertion is the right test for prompt copy — assert the load-bearing phrases are present.)
Step 2. Run — FAIL. Step 3 (implement). Add a react section to the configured branch:
When your message contains kind:"zero" with a slug:
1) activate_skill("board");
2) read_board to see what's already there;
3) brief that home through THIS buyer's lens (concise);
4) author_home_card ONCE — body = the brief, plus exactly one
   ask drawn from the buyer's strongest open question.
Never author more than one card or more than one question per zero.
Step 4. Run — PASS. Step 5. Commit: feat(advisor): react-beat prompt — author one card + one question on a zero.

Task 6 — Key chat to the authenticated userId (server proxy) ships 1A

Create: homezero/app/api/advisor/chat/route.ts (Next.js route handler) Modify: the client chat caller (the component that POSTs to /agents/advisor/…) Test: homezero/app/api/advisor/chat/route.test.ts
Consumes: Clerk server auth (auth() in the Next route → the signed-in userId); the Worker endpoint POST {ADVISOR_WORKER_URL}/agents/advisor/${userId} gated by AUTH_PASSWORD (Bearer).
Produces: chat keyed to the real userId — the SAME DO instance the doorbell wakes — without ever exposing AUTH_PASSWORD to the browser.
Why a proxy, not a client change. The browser can't hold the Worker password, and there's no Clerk auth in the Worker today. So the Next.js server route authenticates the Clerk session, derives userId server-side, and forwards to the DO with the Bearer. This unifies the chat DO with the doorbell DO (both keyed on userId) — the random web-xxxx id is retired.
Step 1 (test). Mock auth() → {userId:'u1'} and a fetch spy; POST a message to the route; assert it forwards to ${ADVISOR_WORKER_URL}/agents/advisor/u1 with the Bearer and the message body, and returns the streamed/JSON response. Assert a signed-OUT request → 401.
Step 2. Run — FAIL. Step 3 (implement). The route: const { userId } = auth(); if (!userId) return 401; then fetch(`${process.env.ADVISOR_WORKER_URL}/agents/advisor/${userId}`, { method:'POST', headers:{ Authorization:`Bearer ${process.env.AUTH_PASSWORD}`, 'Content-Type':'application/json' }, body: JSON.stringify({ message }) }) and pipe the response back.
Step 4. Point the client chat component at /api/advisor/chat (drop the web-xxxx localStorage mint). Step 5. Run tests — PASS. Step 6. Commit: feat(advisor): chat keyed to authenticated userId via server proxy.
1A ships here. A zero wakes the advisor → it authors a real card with a question; the buyer's chat hits the same DO. Empty-board bug dead. Deploy homezero Worker + Convex; smoke with a live onboarding.

Milestone 1B — It has a brain

Outcome: the advisor stands up its own Turso, seeds yearnings at birth, learns from 👍/👎, and shows a "what I'm learning about you" panel. Behavior starts to personalize.

Task 7 — The agent brain DB (per-buyer Turso = filesystem + brain + sessions) the perfect brain

Create: homezero/flue/src/advisor/brainStore.ts (provision/connect/schema/brain CRUD) Create: homezero/flue/src/advisor/advisorFs.ts (a SandboxFactory returning a Turso-backed SessionEnv) Modify: homezero/flue/src/agents/advisor.ts (wire sandbox into createAgent) Test: homezero/flue/test/advisor/brainStore.test.ts + advisorFs.test.ts Modify: homezero/flue/.dev.vars + wrangler.jsonc (advisor Turso group env)
Consumes: the market store.ts pattern (lazy cloudflare:workers import; fns take c: Client so vitest injects :memory:); the Turso Platform API; Flue's SandboxFactory/SessionEnv seam (the sandbox field on createAgent).
Produces: ONE per-buyer Turso db = the agent's whole mind. brainStore.ts: advisorDbName(userId) (sanitize → lowercase alnum/dash, ≤58), provisionAdvisorDb(userId) (idempotent group-create), getAdvisorTursoClient(userId), ensureBrainSchema(c), brain CRUD (upsertYearning, listYearnings, reweightYearning(c,key,delta), writeMetric, readParams(c,skill), writeParam), session-log (appendSessionEntries, readRecentSessions). advisorFs.ts: a SandboxFactory whose createSessionEnv returns a SessionEnv backed DIRECTLY by the Turso files table — readFile/writeFile/readdir/stat/exists/rm are queries, and exec answers grep/glob/find from Turso content/path queries. ONE filesystem — no buffer, no hydrate, no just-bash. Flue's NATIVE read/write/edit/grep/glob tools run straight on Turso; arbitrary scripting bash is unsupported (not needed). No bespoke fs tools.
Env (gitignored): TURSO_API_TOKEN (provision), TURSO_ORG, TURSO_ADVISOR_GROUP, TURSO_ADVISOR_GROUP_TOKEN (db auth). URL DERIVED: libsql://${advisorDbName(userId)}-${TURSO_ORG}.turso.io — no per-buyer secret.
Step 1 (test). c=createClient({url:':memory:'}); await ensureBrainSchema(c); assert tables files, yearnings, skill_metrics, skill_params, sessions exist (idempotent). Files round-trip: saveFiles(c,{'IDENTITY.md':'hi'}); expect((await loadFiles(c))['IDENTITY.md']).toBe('hi'). Brain: round-trip a yearning; reweightYearning(c,'town-vs-land',+0.3) clamps [0,1]. advisorDbName('user_2AbC') → valid Turso name.
Step 2. Run — FAIL. Step 3 (schema). No user_id column anywhere — the DB IS the user:
CREATE TABLE IF NOT EXISTS files(
  path TEXT PRIMARY KEY, content TEXT, updated_at TEXT);          -- the filesystem
CREATE TABLE IF NOT EXISTS yearnings(
  key TEXT PRIMARY KEY, prompt TEXT, confidence REAL DEFAULT 0.2,
  weight REAL DEFAULT 1, status TEXT DEFAULT 'open', source TEXT, last_touched TEXT);
CREATE TABLE IF NOT EXISTS skill_metrics(
  skill TEXT, metric_key TEXT, value REAL, n INTEGER, window TEXT,
  updated_at TEXT, PRIMARY KEY(skill,metric_key,window));
CREATE TABLE IF NOT EXISTS skill_params(
  skill TEXT, param_key TEXT, value TEXT, updated_at TEXT, PRIMARY KEY(skill,param_key));
CREATE TABLE IF NOT EXISTS sessions(
  turn_id TEXT PRIMARY KEY, started_at TEXT, role TEXT, content TEXT);  -- transcript mirror
The SessionEnv's writeFile upserts (INSERT … ON CONFLICT(path) DO UPDATE); readFile = SELECT content … WHERE path=?; readdir splits stored paths on / for the prefix.
Step 4 (provisioning). provisionAdvisorDb(userId) POSTs https://api.turso.tech/v1/organizations/${TURSO_ORG}/databases with { name: advisorDbName(userId), group: TURSO_ADVISOR_GROUP }, Bearer TURSO_API_TOKEN; HTTP 409 = success. getAdvisorTursoClient(userId) builds the URL + auth = TURSO_ADVISOR_GROUP_TOKEN. Test with a fetch spy.
Step 5 (the SessionEnv + native tools). advisorFs.ts: a SandboxFactory whose createSessionEnv returns a SessionEnv over the buyer's brain client — readFile/writeFile/readdir/stat/exists/rm as queries. exec recognizes the grep tool's rg … command and the glob tool's find … command → runs SELECT path,content FROM files WHERE content LIKE/GLOB ? (or path GLOB), formats path:line:text; any other command → a clear "shell unsupported" ShellResult. Wire it: createAgent(() => ({ …, sandbox: advisorFs(userId) })) — lighting up native read/write/edit/grep/glob straight on Turso. Test: seed a files row → env.readFile returns it; env.exec('rg -- water .') over two files returns the matching path:line.
Step 6. Run — PASS. Create the group once (turso group create homezero-advisors) + group token; set the four env values. Step 7. Commit: feat(advisor): per-buyer brain DB + Turso-backed sandbox under the native file tools.
The agent greps and edits its files with Flue's NATIVE tools — bytes live in Turso, no container, no bespoke fs API. Connect any libSQL client to a buyer's brain db and you see its files, yearnings, and transcript. Flue's flue_* engine tables stay in the DO.

Task 8 — Birth on the brain DB: IDENTITY → the filesystem + seed yearnings

Modify: homezero/flue/src/advisor/identity.ts (read/write IDENTITY via the Turso files table, not the DO workspace) Modify: homezero/flue/src/advisor/tools.ts (check_identity, write_identity, finish_birth) Test: homezero/flue/test/advisor/birth-brain.test.ts
Consumes: provisionAdvisorDb, getAdvisorTursoClient, ensureBrainSchema, the brain client's files-table read/write, upsertYearning (Task 7); the onboarding answers in the birth message.
Produces: birth provisions the buyer's brain DB, writes IDENTITY.md into the files table (NOT the DO workspace), and seeds yearnings. isConfigured/check_identity/write_identity now read/write the IDENTITY.md row in files; seedYearnings(c, answers) (no userId column — the DB is the user).
Step 1 (test). With an injected :memory: brain client: write_identity('# Who I am…') → the IDENTITY.md row in files holds it; check_identity → configured:…; empty db → unconfigured. Seed: assert the 7 universal-core keys (budget-comfort, town-vs-land, fixer-appetite, owned-water, timeline, who-its-for, commute) at confidence 0.2, status 'open' + a custom yearning from a non-core answer.
Step 2. Run — FAIL. Step 3 (implement). Repoint identity.ts (IDENTITY_PATH='IDENTITY.md') from WorkspaceFileSystem to a files-table read/write on the brain client. Add seedYearnings(c, answers) to brainStore.ts (universal core as a constant {key,prompt}[]; known answers → custom yearnings). In finish_birth: await provisionAdvisorDb(userId); const c = await getAdvisorTursoClient(userId); await ensureBrainSchema(c); await seedYearnings(c, answers) (idempotent).
Step 4. Run — PASS. Step 5. Commit: feat(advisor): birth provisions the brain DB; IDENTITY + yearnings live in Turso.
IDENTITY leaves the DO workspace for the files table — so identity is externally readable from the brain db like everything else. The DO workspace is no longer the source of truth for advisor data.

Task 9 — Brain + reactions skills & tools brain skill shared

Create: skills/brain/SKILL.md · skills/reactions/SKILL.md (under flue/src/advisor/skills/) Modify: homezero/flue/src/advisor/tools.ts (brain_read/write, read_reactions) + advisor.ts (register skills) Test: homezero/flue/test/advisor/brain-tools.test.ts
Consumes: brainStore CRUD (Task 7); board.agentCards returns each card's ask.reactedAt/ask.answer (board.ts) — the reactions signal.
Produces: tools brain_read (list yearnings/params), brain_write (upsert yearning / write param), read_reactions (derive 👍/👎 + answers from agentCards); native skills brain (the Turso playbook, reusable by the market) and reactions (signal→yearning-update playbook).
Step 1 (test). With an injected :memory: client + a fake agentCards payload, assert read_reactions returns a normalized list ({slug, reaction:'up'|'down'|null, answered}) and brain_write persists a yearning reweight.
Step 2. Run — FAIL. Step 3 (implement). Add the tools (closure-bound to userId, using the brain client resolved in-Worker / injected in test). Write brain/SKILL.md (when to read vs write, the schema, never expose raw private data to the buyer) and reactions/SKILL.md (a 👍 raises the relevant yearning's confidence, a 👎 lowers/flips it; an answer resolves it). Register both in advisor.ts skills array.
Step 4. Run — PASS. Step 5. Commit: feat(advisor): brain + reactions skills and tools.

Task 10 — Learning-panel projection to Convex

Create: homezero/convex/board.ts → setLearningPanel (internalMutation) + schema field Modify: homezero/flue/src/advisor/tools.ts (project_learning tool) Test: homezero/convex/learningPanel.test.ts
Consumes: Convex board schema; callConvex; yearnings (Task 7).
Produces: internal.board.setLearningPanel({ userId, items: [{label, confidence}] }) + a project_learning tool that pushes a PUBLIC-SAFE confidence subset (labels + bars only, never the private prompt/why) for the board's "what I'm learning about you" panel.
Step 1 (test). convex-test: call setLearningPanel with two items; query it back scoped to the user; assert only label+confidence are stored (no private fields).
Step 2. Run — FAIL. Step 3 (implement). Add the schema field (a small array on the user's board doc or a dedicated table), the internalMutation, and the project_learning tool that maps open yearnings → {label, confidence} and calls it.
Step 4. Run — PASS. Step 5. Commit: feat(advisor): project public-safe learning panel to Convex.
Turso stays source of truth; Convex gets only the bars the buyer may see. No prompts, no "why".

Milestone 1C — It has rhythm

Outcome: the advisor runs on its own clock — hourly tend, nightly scout + dream — the session log mirrors to the brain DB, and the dream closes the self-improvement loop on the full transcript.

Task 11 — The rhythm primitive (extend) + the session-log mirror

Modify: homezero/flue/src/agents/advisor.ts (export const cloudflare = extend(...)) Test: homezero/flue/test/advisor/rhythm.test.ts
Consumes: extend from @flue/runtime/cloudflare; base CloudflareAgentLike (onStart, scheduleEvery, schedule).
Produces: an attached extension registering the recurring beats — scheduleEvery(3600,'tend'), and nightly schedule(cronString,'scout') + schedule(cronString,'dream') + schedule(cronString,'morningBrief') — plus stub methods that dispatch into the agent. Each beat method is named so the SDK can invoke it on alarm.
Step 1 (test). Instantiate the extension's class over a fake base that records scheduleEvery/schedule calls; run onStart(); assert scheduleEvery(3600,'tend') was registered and the three nightly beats scheduled. (Idempotency: calling onStart twice doesn't double-register — guard with a state flag.)
Step 2. Run — FAIL. Step 3 (implement).
export const cloudflare = extend({
  base: (Base) => class extends Base {
    async onStart() {
      if (this.state?.rhythmOn) return;
      await this.scheduleEvery(3600, 'tend');
      await this.schedule('0 13 * * *', 'scout');   // nightly UTC
      await this.schedule('30 13 * * *', 'dream');
      await this.schedule('0 14 * * *', 'morningBrief');
      this.setState({ ...this.state, rhythmOn: true });
    }
    async tend(){ /* Task 12 */ }
    async scout(){ /* Task 13 */ }
    async dream(){ /* Task 14 */ }
    async morningBrief(){ /* Task 15 */ }
  },
});
Step 5 (session-log mirror). Add an onTurnEnd-style hook to the extension: read new rows from the DO's own flue_session_entries (via this.sql) since the last watermark, and appendSessionEntries(c, entries) into the buyer's Turso sessions table; advance the watermark in this.state. This gives the dream (Task 14) the full recent transcript AND makes it browsable from any libSQL client. Test the mirror against a fake this.sql returning two entries → assert both land in the Turso sessions table once (idempotent on the watermark).
Step 6. Run — PASS. Step 7. Commit: feat(advisor): rhythm via extend/scheduleEvery + session-log mirror to Turso.

Task 12 — Tend beat + journal (memory skill, NATIVE file ops)

Modify: homezero/flue/src/agents/advisor.ts (the tend() beat) Create: flue/src/advisor/skills/memory/SKILL.md Test: homezero/flue/test/advisor/tend.test.ts
Consumes: the NATIVE file tools (read/write/edit/grep) over the Turso fs (Task 7); read_board (Task 2).
Produces: NO bespoke journal tools. The memory base skill (a SKILL.md) teaches the agent to keep JOURNAL.md with its native tools — read it, edit/write to append a tagged line, grep it to recall. The tend beat = a short housekeeping turn (read board, tidy clusters, append to JOURNAL.md what moved).
Step 1 (test). Drive the SessionEnv over an injected :memory: brain client: simulate the agent's write then edit on JOURNAL.md (append a slug-tagged line); assert the files row holds both lines; assert env.exec('rg -- 123-main .') finds the tagged line. (No journal tool to test — exercise the native fs path.)
Step 2. Run — FAIL. Step 3 (implement). Write skills/memory/SKILL.md (build-time base skill: what's worth journaling, tag entries by home slug, how to append with edit, the dream prunes). The tend() beat dispatches a bounded model turn with the memory + board skills available — the model uses native read/edit on JOURNAL.md.
Step 4. Run — PASS. Step 5. Commit: feat(advisor): tend beat + memory skill (journal via native file tools).

Task 13 — Scout beat (reads the MARKET's Turso)

Modify: advisor.ts scout(); tools.ts (scout_query) Create: flue/src/advisor/skills/scout/SKILL.md Test: homezero/flue/test/advisor/scout.test.ts
Consumes: the MARKET's Turso (env TURSO_DATABASE_URL/_AUTH_TOKEN — both DOs share the Worker env; open a READ client on the market's listings); yearnings (Task 7); author_home_card (Task 2); board.agentCards to exclude already-on-board homes.
Produces: scout_query() — structural rank of unzeroed market listings by active yearnings — and the scout beat that authors ≤N scout cards with placedBy:{by:'advisor'}. Phase-2 swaps ranking to vector_top_k.
Step 1 (test). Seed an in-memory "market" listings table + yearnings; assert scout_query returns homes ranked by yearning fit, excludes slugs already on the board, and caps at N.
Step 2. Run — FAIL. Step 3 (implement). scout_query opens a read client on the market db (injected in test), runs a structural SELECT scored against the buyer's open yearnings, filters out on-board slugs; write scout/SKILL.md (rank by yearnings, surface a few not many, cite the "why"). The scout() beat runs once nightly, authors the cards.
Step 4. Run — PASS. Step 5. Commit: feat(advisor): nightly scout beat reading the market brain.
Never scrapes Zillow; reads only the market's already-gathered listings. Cross-agent read is fine — both DOs live in the one homezero-flue Worker.

Task 14 — Dream beat (the self-improvement loop)

Modify: homezero/flue/src/agents/advisor.ts (the dream() beat) Seed a PLAYBOOK.md per tunable skill into the Turso files table (e.g. 'board/PLAYBOOK.md') Test: homezero/flue/test/advisor/dream.test.ts
Consumes: brainStore (reweightYearning, writeMetric, writeParam, readParams, readRecentSessions); read_reactions (Task 9); the NATIVE file tools over Turso (Task 7).
Produces: the nightly consolidation, reading the FULL recent transcript from the Turso session log — (1) answers/reactions → yearning confidence; (2) per-skill metric from real signal → writeParam + rewrite the skill's PLAYBOOK.md using the native read/edit tools (it's a row in the files table); (3) summarize/prune JOURNAL.md with edit; (4) board re-org. NO bespoke playbook tools — the beats grep/read their <skill>/PLAYBOOK.md natively.
Step 1 (test). Seed a yearning + a few reactions; run the reweight unit; assert confidence moved the right direction and clamped. Seed a board/PLAYBOOK.md files row + two sessions rows; assert readRecentSessions returns the transcript and the SessionEnv readFile('board/PLAYBOOK.md') returns the playbook for the dream to rewrite.
Step 2. Run — FAIL. Step 3 (implement). The dream() beat: readRecentSessions (full transcript since the last dream) + read_reactions → reweightYearning; compute the board skill's engagement metric → writeMetric + writeParam; dispatch a bounded model turn that rewrites each tunable skill's PLAYBOOK.md from its metric + the transcript using the native read+edit tools; summarize JOURNAL.md. The react/scout/tend prompts gain "grep/read your <skill> PLAYBOOK.md before acting".
Step 4. Run — PASS. Step 5. Commit: feat(advisor): nightly dream — read full session log, reweight yearnings, self-tune playbooks.
The self-improving loop, all in the brain DB. The dream reads the full transcript (Turso sessions), tunes params + rewrites playbooks (Turso files) — the agent's whole learning loop lives in its one addressable db. No dependency on Flue runtime skill-rediscovery.

Milestone 1D — It greets

Outcome: a simple daily WhatsApp morning briefing — one short, friendly check-in. Stubbed and tunable, board-card fallback if a buyer isn't WhatsApp-wired.

Task 15 — Morning-brief beat (compose the message)

Modify: advisor.ts morningBrief() Create: flue/src/advisor/skills/morning/SKILL.md Test: homezero/flue/test/advisor/morning.test.ts
Consumes: read_board, scout results, yearnings; the persona constraint (home shopper, light check-in, don't overwhelm).
Produces: the morningBrief beat that composes ONE short message — greeting → the single best overnight thing (one scout find OR one board change) → one gentle question → board link — and hands it to the channel (Task 16). Quiet night → a one-liner or skip.
Step 1 (test). Give the composer a board+scout state with one new find; assert the composed message is short (≤ ~4 lines), contains exactly one question and the board link, and references the single highlight. Give it an empty overnight state; assert it returns the quiet-day one-liner or a skip signal.
Step 2. Run — FAIL. Step 3 (implement). Write morning/SKILL.md (the tone + the one-focus rule + don't-overwhelm). morningBrief() = a bounded model turn that produces { text, skip }, then calls sendMorning(userId, text) (Task 16).
Step 4. Run — PASS. Step 5. Commit: feat(advisor): morning-brief beat composes a short daily check-in.

Task 16 — WhatsApp send path (simple) + fallback ships 1D

Create: homezero/flue/src/advisor/whatsapp.ts Test: homezero/flue/test/advisor/whatsapp.test.ts
Consumes: the buyer's phone/opt-in (from onboarding, if present); author_home_card for the fallback; WhatsApp creds (env, gitignored).
Produces: sendMorning(userId, text) — if the buyer has a number + opt-in, send via the WhatsApp Cloud API; else fall back to authoring a "good morning" board card so it always works.
Step 1 (test). With a fake fetch + a buyer-with-number, assert sendMorning POSTs to the WhatsApp API with the text. With a buyer-without-number, assert it calls author_home_card (the board fallback) instead and does NOT call WhatsApp.
Step 2. Run — FAIL. Step 3 (implement). whatsapp.ts: resolve the buyer's number/opt-in; if present, fetch the Cloud API send endpoint with the env creds (a template-less session message is fine for the stub — note Meta's 24h-window/template requirement as a follow-up TODO in the code); else write the board card. Keep it simple — winged, tunable later.
Step 4. Run — PASS. Step 5. Commit: feat(advisor): simple WhatsApp morning send with board-card fallback.
Known follow-ups (out of scope, flagged): Meta business-initiated template approval, number-capture + opt-in UX, send-time tuning. The beat ships working with the fallback regardless.
▶ Handoff

Execution

Execute with subagent-driven-development: a fresh implementer subagent per task (Opus), a task review (spec compliance + quality) after each, a broad whole-branch review at the end. Work on feature/advisor-loop in the homezero repo; open a PR, squash-merge per milestone (1A is independently shippable).

Self-review (done while writing)

Market agent (Phase 2) is captured separately in the market spec — not built here.