Sprint 7 — depth on demand
The framing for Sprint 7 came from Requirements §5.13.9: “stay light by default, lean in when it matters.” The chat surface that shipped in Sprint 5 grounds answers in tools (list_tasks, list_documents, run_predictions_now) — cheap, fast, factual. The hard turns — “build me a maintenance plan for the new car,” “design a weekly cleaning routine,” “if I get a second car next year, how should the maintenance schedule change?” — those need a different model and a different mode. M7’s job was to wire that without making everyone pay the premium-reasoning bill on every turn.
Two evenings, five PRs, all merged by Friday night. Six total LLM calls per escalated turn (chat call + tool result + plan call), about $0.02 per escalation on Sonnet 4.6. The chat eval extended from 5 fixtures at $0.04 to 17 at $0.22 — most of the cost increase is the escalation positives actually generating plans.
What shipped
generate_planrole on AnthropicAdapter. The role was declared inpackages/shared/src/llm/types.tssince Sprint 2; the catalog hadclaude-sonnet-4-6mapped to it; the router enforced itstool_usecapability gate. What had been missing was an actual adapter method to back it. PR #93 addedAnthropicAdapter.generate_plan(input, options)following the same shape aschat/classify/extract_document— typed input/output, versioned prompt, routes throughcallMessages+buildResult. Live smoke against the API: a 12-month maintenance plan for a 2023 Mazda CX-5 + 2018 Honda Civic in Quebec. The plan referenced winter-tire deadlines, SAAQ inspection nuances, and a Transport Canada recall lookup for the high-mileage Civic — $0.014, 312 in / 865 out tokens. First non-trivial use of the role-routed LLM abstraction since it was scaffolded in Sprint 2.escalate_to_planchat tool. The chat model decides when to call this. The tool’s description — and this is the load-bearing piece — embeds the §5.13.9 trigger heuristics verbatim: 5 SHOULD-call categories (multi-step plan, cross-asset synthesis, conditional reasoning, routine generation, explicit phrasing) and 4 SHOULD-NOT categories (factual questions, single-step actions, app-meta, status checks). Calling the tool routes throughgetPlanner()→AnthropicAdapter.generate_plan→ returns the plan to the chat model, which paraphrases it into its reply. The result persists as achat_messagesrow withrole='tool'so follow-up turns can reason against the full plan rather than the chat assistant’s summary of it.- “Domi is thinking harder…” 1.5s indicator. Non-blocking inline indicator that surfaces when an
escalate_to_plantool call has been outstanding for more than 1.5 seconds. Below 1.5s the UI still feels responsive; above 1.5s the user wants signal that something is happening. CSS-only emerald spinner,role="status"for screen readers, auto-dismisses when the response renders. PR #95 was built offmainahead of #94 landing — the detection was a silent no-op until the tool fired, so the two PRs were genuinely independent and could land in either order. /thinkand/quickslash overrides + global toggle. Per-turn user control:/think your-messagestrips the prefix client-side and sends aforceoverride; the server adds a system-prompt nudge pushing toward escalation./quick your-messagesendssuppress; the server filtersescalate_to_planout of the tools array for that turn. Plus a global toggle in Settings (Chat preferences, the third Settings IA section): “Allow chat to escalate to premium reasoning when needed,” default ON. Precedence: per-turn slash always wins; global OFF + no slash = tool not exposed; global OFF +/think= escalation still happens (slash is explicit user intent).- Chat eval extended with 12 escalation fixtures. Five positives (one per SHOULD-call category — must call escalate_to_plan), four negatives (one per SHOULD-NOT category — must NOT call), three ambiguous cases logged but not graded (per spec §14.2 “judged by reviewer”). The runner gained a
tools_must_not_callpredicate — first time the eval asserts a tool wasn’t called. 17/17 pass on Sonnet 4.6 at $0.224 total. Baseline written; future prompt edits get caught by the eval before they regress.
What surprised me
The tool description is the prompt-engineering surface. Half the work of getting escalate_to_plan to behave was deciding what to write in its description string. The 5 SHOULD-call + 4 SHOULD-NOT categories from §5.13.9 are pasted verbatim. I’d have expected to need a separate prompt-engineering doc, an iterative tuning pass, and probably a couple of cases where the model would surprise me. The eval ran 17/17 first try. Sometimes the spec is doing the work; you just have to copy it into the right place.
Sprint 6’s parallel-branch lessons actually stuck. Sprint 6 wrapped up with three different flavors of merge-friction pain — Drizzle snapshot collisions, the empty-body --custom migration, the main-merge regression. Going into Sprint 7 I designed the dependency graph upfront: #88 (foundational) → #89 (depends on #88) → #90 (independent of #89, can run parallel against main) → #91 (conflicts with #89, wait for it) → #92 (needs the route live, wait for #91 OR build off it). Worked exactly as planned. Zero conflicts, no rebases beyond standard “pull main, merge in.” The “verify the PR is still open before pushing follow-ups” mental check that I added to the post-merge habit list at the end of Sprint 6 — that one apparently took.
The “first eval that asserts in two directions” felt like a small thing and is actually a big one. Earlier evals (classify, predict_task, the original chat 5) all assert “the right tool was called.” For the escalation eval, the negative cases needed “the wrong tool was NOT called” — and “wrong” is just escalate_to_plan on a status-check trigger. Adding tools_must_not_call was a 20-line runner change. But it changes what the eval can express. Future tools can now be eval’d both ways: “we want list_tasks here, and we don’t want run_predictions_now firing too.”
The carve-out for role='tool' persistence felt nuanced and turned out clean. Sprint 6 said “tool messages are turn-local; the model recomputes them per turn.” That works for list_tasks because the answer to “what tasks do I have?” depends on the current DB state and should be re-fetched each turn. It does not work for escalations because the plan is an LLM-generated artifact that the user paid (literally, in tokens) to produce, and recomputing it next turn would be both expensive and incoherent. The schema’s chat_role enum had a 'tool' value we hadn’t been using; now it has exactly one user. The “deliberate exception” pattern is well-known in software but always feels like a smell when you write it; this one feels right because the cost-of-recomputation tradeoff is asymmetric.
Per-turn slash overrides were trivially clean once I read the AI SDK API for them. I’d spent a few minutes thinking about how to extend the DefaultChatTransport body builder, plumb the override through transport rebuilds on every turn, etc. Then I read useChat’s sendMessage(message, options) signature — options.body is merged into the request body. Done in five lines. There’s a subtle lesson here: the AI SDK has more affordances than I was treating it as having, and I keep forgetting to read the type definitions before writing custom code. Adding to the post-PR mental checklist alongside “verify the PR is still open.”
Where Sprint 8 picks up
M8 — multi-provider adapters + eval matrix. OpenAI, Gemini, OpenRouter adapters behind the existing LlmAdapter interface. Capability gates (vision, tool_use, privacy_approved) enforced at strategy save time so a user can’t map extract_document to a non-vision model and ship it. Cross-provider eval matrix for at least one role to prove the abstraction holds — the test is whether chat works on OpenAI’s GPT-4o-mini with the same prompts and tools, or whether the implicit assumptions about Anthropic’s tool-calling protocol leak through.
This sprint had a decision point at the end (per Dev Plan §8): “if behind, cut OpenRouter and Gemini for V1, ship Anthropic + OpenAI only.” I’m going into M8 betting we land at least Anthropic + OpenAI and at least one of the other two. The interface-versus-implementation cost ratio favors shipping the third adapter once the second is done, because the second is what proves the interface. We’ll see.
The other thing I’m carrying into M8: cost-line UX with parent-child linking (per spec §5.13.6). The escalation flow already records parent_call_id shape in provenance JSON; what’s missing is the llm.calls partitioned table to put it in. That table belongs with M8 too — multi-provider eval is meaningless without per-call cost telemetry.