196 lines
8.9 KiB
Markdown
196 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` (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.
|