# M7 — Reflection: Design **Status:** approved **Date:** 2026-06-01 **Milestone:** M7 — Reflection (the deferred AI **reviewer** role, promoted into the main loop) ## Purpose Close the loop. Through M6 the system has three live AI roles — Coach, DriftJudge, Nudge — but nothing looks *back*. M7 adds the fourth role, the **Reviewer**: when a session ends, it reflects on what just happened, read against your recent sessions, and produces two short lines — - a **recap**, shown on the Review screen (how the session went), and - a **carry-forward**, which grounds the coach the next time you plan (what to do differently). This is the "Focus OS Reflection" step from the roadmap. It makes the loop self-reinforcing: each session's takeaway sharpens the next session's plan. The whole feature is one cheap async call per session, never blocking, and degrades gracefully — if the AI backend is off or slow, Review/End/Planning behave exactly as they do today. ## Design constraints Two non-negotiables shaped every decision below: - **Efficient.** One LLM call per session, fired once on entering Review. The "recent sessions" context is a local file read, not an LLM cost. The prompt carries the finished session plus a few *compact* prior summaries (outcome + top buckets, never raw event logs), so it stays small. The result is computed once and persisted; the next planning cycle reads it from disk and does **not** re-run the reviewer. - **Low friction.** It runs automatically (no "request reflection" button). It is **never blocking** — the **End** button works immediately whether or not the reviewer has returned. The carry-forward is **auto-applied** as coach grounding next time; there is no approve/dismiss step. ## The new AI role (`ai` package) A leaf role that mirrors Coach/DriftJudge/Nudge: it takes only primitives and imports neither `store` nor `session`. The controller is responsible for turning session data into the strings this role consumes. ```go // Reflection is the reviewer's output: two short, single-line fields. type Reflection struct { Recap string // backward-looking, ≤1 short line — shown on Review CarryForward string // forward-looking, ≤1 short line — grounds the next // coach and is shown on the next Planning screen } // Review reflects on a just-finished session, read against recent history. // finished: a compact description of the session that just ended. // history: a compact description of the last few prior sessions ("" if none). func (b *Backend) Review(ctx context.Context, finished, history string) (Reflection, error) ``` - A new prompt builder composes a Reviewer prompt from `finished` and `history`, instructing the model to return **at most one short line per field** so output stays bounded. - A parser extracts the two lines from the backend output, following the existing role-parsing pattern in the `ai` package. - **Graceful fallback:** any error, empty output, or unparseable result yields a zero `Reflection{}` (both fields ""), which the rest of the system treats as "no reflection available." `Review` never panics and never blocks. The prompt is built so that an empty `history` (the first-ever session) still produces a sensible recap from `finished` alone. ## Orchestration (`session.Controller`) The controller owns all orchestration, reusing the established async + generation-counter + graceful-degradation pattern already used for the coach, tasks, and knowledge fetches. ### Fetch on entering Review `enterReview` (reached from both `Complete` → `completed` and `Expire` → `expired`) fires `startReflectionFetchLocked()`: 1. Increment a `reflectionGen` counter and capture it for this fetch. 2. Build `finished` from the **in-memory** frozen stats of the session that just ended: next action, success condition, outcome, switch count, and the top app time buckets. 3. Build `history` by reading the **last 5 prior** `SessionSummary` records from `audit.jsonl`. The just-finished session is **not** in the chain yet — it is appended only at `End` — so there is no double-counting. 4. In a goroutine, call `reviewer.Review(ctx, finished, history)` under a timeout. On return, re-acquire the lock; if `reflectionGen` still matches the captured value, cache `reflectionRecap` and set `carryForward` (**latest-wins**); otherwise discard the result as stale. Then notify. The generation guard ensures a slow review from a superseded session can never overwrite a newer one: the most recent *completed* review wins. ### Grounding the next coach — no interface change `grounding` is already a free-form string parameter on `ai.Coach` (added in M6). M7 needs **no** change to the `ai.Coach` signature: in `RequestCoach` the controller composes the existing knowledge profile text **and** the current `carryForward` into that one `grounding` string (profile block, then a short "Last session:" line). M7 is fully additive to the AI interface. `carryForward` is latest-wins and survives `End` (it is not cleared with the commitment/stats), so it is present when the next Planning begins. ### State projection The State view gains a small reflection projection so the browser can render it: ```go type ReflectionView struct { Status string // "idle" | "pending" | "ready" | "absent" Recap string // shown on Review CarryForward string // shown on Planning } ``` - On **Review**, the view carries `Status` + `Recap` (and the `CarryForward` preview). - On **Planning**, the view carries the `CarryForward` line. Unlike the M6 *profile* (large and private, deliberately kept off the wire), the reflection lines are short and **exist to be displayed**, so they are intentionally included in the State payload sent to the browser. ## Persistence Snapshot-only, latest-wins. The persisted snapshot JSON gains `reflectionRecap`, `carryForward`, and a small `reflectionStatus` enum (idle/pending/ready/absent). There are **no** changes to `audit.jsonl`, no new files, and no new on-disk format. The permanent, hash-chained `SessionSummary` is untouched. One small additive reader is needed on the store: ```go // RecentSessions returns up to n most-recent summaries from the audit chain, // most-recent first (or oldest-first — fixed by the plan), [] if the log is // absent or empty. func RecentSessions(path string, n int) ([]SessionSummary, error) ``` Today's `readSummaries` is unexported; `RecentSessions` exposes a bounded slice of it for the controller to format into `history`. ## UI (`web` static assets) - **Review screen:** the `Recap` rendered as a subtle line (nudge-band style), with `pending` and `absent` states. The **End** button works immediately regardless of reflection status. - **Planning screen:** the `CarryForward` rendered as a quiet one-liner ("Last time: …"), mirroring the M6 knowledge indicator. No buttons, no added clicks anywhere. ## Daemon wiring (`cmd/antidriftd/main.go`) The reviewer backend is wired into the controller (`ctrl.SetReviewer(...)`), gated on AI-backend availability exactly like the other roles. With no backend configured, the reviewer is simply absent and reflection silently does nothing. ## Error handling / graceful degradation - Backend off, error, empty output, unparseable result, or no prior history → nothing is shown; Review, End, and Planning behave exactly as today. - The reviewer **never blocks a transition**. `End` does not wait for it. - The generation counter discards late results from a superseded review. ## Testing - **`ai`:** the Reviewer prompt includes both `finished` and `history`; the parser extracts two lines; error/blank/unparseable input yields an empty `Reflection`. - **`session`:** reflection is fetched on `enterReview`; the result is cached and rides the snapshot; a stale (superseded-generation) result is discarded; the `carryForward` composes into the next coach's `grounding`; everything degrades gracefully when no reviewer is set; `RecentSessions` returns the last *n* summaries in the expected order. - **`web`:** the Review payload carries the `Recap`; the Planning payload carries the `CarryForward`; reflection text is intentionally present on the wire. ## Out of scope (this milestone) - **A durable reflection history** (`reflections.jsonl`). Nothing in the loop needs it — the next coach only needs the latest carry-forward — and the permanent `SessionSummary` still records outcome/buckets/switches for every session. Promoting to a durable log later would be an additive change. - **Changing the `ai.Coach` signature.** Grounding is already a free-form string. - **Refactoring `session.go`.** It is ~1054 lines and M7 adds another per-role async-fetch block; the repetition across the coach/tasks/knowledge/reviewer fetchers is a fair future consolidation target, but extracting it now would destabilize four working roles for no functional gain. M7 follows the established per-role pattern for consistency and reviewability.