Files
antidrift/docs/superpowers/specs/2026-06-01-m7-reflection-design.md
T
2026-06-01 09:11:00 -04:00

8.9 KiB

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.

// 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 Completecompleted and Expireexpired) 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:

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:

// 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.