Add M7 reflection design spec
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user