From f417faab9a0b4f5194eadbe6ca1ad4a35e0b7430 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 09:11:00 -0400 Subject: [PATCH] Add M7 reflection design spec --- .../specs/2026-06-01-m7-reflection-design.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-m7-reflection-design.md diff --git a/docs/superpowers/specs/2026-06-01-m7-reflection-design.md b/docs/superpowers/specs/2026-06-01-m7-reflection-design.md new file mode 100644 index 0000000..5a50f28 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-m7-reflection-design.md @@ -0,0 +1,195 @@ +# 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.