Files
antidrift/docs/superpowers/specs/2026-06-01-m7-reflection-design.md
T
felixm e3cfe8c70c Assert the reviewer fires exactly once per session
Closes the one coverage gap from the final review: the spec's
'one async call per session' efficiency claim is now directly
verified via the reviewer call count, not just implied. Also drops a
stale CarryForward-preview mention from the Review projection in the
design spec to match the shipped (cleaner) behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:47:27 -04:00

195 lines
8.9 KiB
Markdown

# 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`.
- 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.