Compare commits

..

8 Commits

Author SHA1 Message Date
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
felixm 95e14e6340 Show the recap on Review and the carry-forward on Planning
The Review screen renders the reviewer's one-line recap (quiet pending
line, then the recap); the Planning screen renders last session's
carry-forward as a 'Last time: …' one-liner, mirroring the knowledge
indicator. Updates the README Status section for M7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:42:21 -04:00
felixm ff100b2472 Wire the reviewer and assert reflection on the wire
main injects the AI service as the reviewer alongside the other roles.
A web test drives a session to Review and asserts the recap rides the
state payload, then to the next Planning and asserts the carry-forward
does too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:38:13 -04:00
felixm a4f0c00cac Reflect on entering Review and ground the next coach
The controller fetches a reflection asynchronously on enterReview
(generation-guarded, non-blocking, graceful) and caches a one-line
recap plus a latest-wins carry-forward. The recap projects onto Review,
the carry-forward onto the next Planning, and both ride the snapshot.
RequestCoach composes the carry-forward into the coach's existing
free-form grounding string, so ai.Coach is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:36:50 -04:00
felixm 25f56b3c71 Add RecentSessions reader over the audit chain
Exposes the last n session summaries (oldest-first) for the reviewer to
read as recent-history context. Missing/empty chain or n<=0 yields nil.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:26:57 -04:00
felixm e4e34b092c Add reviewer role to the ai port
A fourth leaf role: Review(finished, history) returns a two-line
Reflection (recap + carry_forward) over the same CLI backend as the
coach. Primitive-only, with a JSON prompt and a tolerant parser that
rejects a blank recap but allows an empty carry_forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:24:04 -04:00
felixm 82fd7d35a5 Add M7 reflection implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:20:55 -04:00
felixm f417faab9a Add M7 reflection design spec 2026-06-01 09:11:00 -04:00
14 changed files with 1903 additions and 9 deletions
+10
View File
@@ -23,6 +23,16 @@ go test ./...
## Status ## Status
M7 (reflection): when a session ends, a fourth AI role — the reviewer —
reflects on it, read against your recent sessions, and produces two short
lines: a recap shown on the Review screen, and a carry-forward takeaway that
grounds the coach the next time you plan. It runs once asynchronously on
entering Review, never blocks the End button, and degrades gracefully — with
no backend (or a slow/failed call) Review and Planning behave exactly as
before. The carry-forward is snapshot-persisted (latest-wins) and composes
into the coach's grounding; the reflection lines are short and cross the wire
by design, while the knowledge profile still does not.
M6 (knowledge port): the planning coach now sharpens intents against who you M6 (knowledge port): the planning coach now sharpens intents against who you
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities, via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities,
+2 -1
View File
@@ -45,7 +45,8 @@ func main() {
ctrl.SetCoach(svc) ctrl.SetCoach(svc)
ctrl.SetDriftJudge(svc) ctrl.SetDriftJudge(svc)
ctrl.SetNudge(svc) ctrl.SetNudge(svc)
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name()) ctrl.SetReviewer(svc)
log.Printf("ai: %s backend (coach + drift judge + nudge + reviewer)", backend.Name())
} }
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is // Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
# 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.
+84
View File
@@ -0,0 +1,84 @@
package ai
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Reflection is the reviewer's output: two short, single-line fields.
type Reflection struct {
Recap string // backward-looking: how the session went (shown on Review)
CarryForward string // forward-looking: one takeaway for the next session
}
// Reviewer reflects on a just-finished session, read against recent history. It
// takes primitives, not domain/store types, so ai stays a leaf package.
//
// finished: a compact description of the session that just ended.
// history: a compact description of the last few prior sessions ("" if none).
type Reviewer interface {
Review(ctx context.Context, finished, history string) (Reflection, error)
}
// ErrInvalidReflection marks output that parsed as JSON but lacked a usable
// recap, so callers can distinguish "no attempt" from "bad attempt".
var ErrInvalidReflection = errors.New("ai: invalid reflection")
// Review makes Service satisfy Reviewer over the same backend as Coach.
func (s *Service) Review(ctx context.Context, finished, history string) (Reflection, error) {
out, err := s.backend.Run(ctx, buildReviewPrompt(finished, history))
if err != nil {
return Reflection{}, err
}
return parseReflection(out)
}
func buildReviewPrompt(finished, history string) string {
preamble := `You are a focus reviewer. A work session just ended. Reflect on it in two short lines.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"recap": "<one sentence: how this session went>", "carry_forward": "<one sentence: the single most useful thing to do differently next session>"}
Rules:
- recap: one short sentence, backward-looking, grounded in the session below.
- carry_forward: one short, actionable sentence for the next session. It may reference patterns across the recent sessions if any are given.
- Keep each field to a single short sentence.`
hist := ""
if strings.TrimSpace(history) != "" {
hist = "\n\n## Recent sessions (oldest first)\n" + history
}
return preamble + "\n\n## Session that just ended\n" + finished + hist
}
type rawReflection struct {
Recap string `json:"recap"`
CarryForward string `json:"carry_forward"`
}
// parseReflection extracts a Reflection from raw CLI output. A blank recap is
// rejected (ErrInvalidReflection); an empty carry_forward is allowed.
func parseReflection(s string) (Reflection, error) {
if strings.TrimSpace(s) == "" {
return Reflection{}, ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return Reflection{}, ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
}
var raw rawReflection
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
}
recap := strings.TrimSpace(raw.Recap)
if recap == "" {
return Reflection{}, ErrInvalidReflection
}
return Reflection{Recap: recap, CarryForward: strings.TrimSpace(raw.CarryForward)}, nil
}
+77
View File
@@ -0,0 +1,77 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestReviewPromptIncludesSessionAndHistory(t *testing.T) {
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", "HISTORY-BLOCK"); err != nil {
t.Fatalf("review: %v", err)
}
if !strings.Contains(fb.gotPrompt, "FINISHED-BLOCK") {
t.Fatalf("prompt missing finished session: %s", fb.gotPrompt)
}
if !strings.Contains(fb.gotPrompt, "HISTORY-BLOCK") || !strings.Contains(fb.gotPrompt, "Recent sessions") {
t.Fatalf("prompt missing history block: %s", fb.gotPrompt)
}
}
func TestReviewPromptOmitsEmptyHistory(t *testing.T) {
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", ""); err != nil {
t.Fatalf("review: %v", err)
}
if strings.Contains(fb.gotPrompt, "Recent sessions") {
t.Fatalf("empty history must not add a history header: %s", fb.gotPrompt)
}
}
func TestReviewServiceParsesReflection(t *testing.T) {
fb := &fakeBackend{out: `sure: {"recap":"held focus on the port","carry_forward":"start in the editor next time"}`}
refl, err := NewService(fb).Review(context.Background(), "f", "h")
if err != nil {
t.Fatalf("review: %v", err)
}
if refl.Recap != "held focus on the port" || refl.CarryForward != "start in the editor next time" {
t.Fatalf("bad reflection: %+v", refl)
}
}
func TestReviewServiceBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Review(context.Background(), "f", "h"); err == nil {
t.Fatal("want backend error")
}
}
func TestParseReflectionEmpty(t *testing.T) {
if _, err := parseReflection(""); !errors.Is(err, ErrEmptyResponse) {
t.Fatalf("want ErrEmptyResponse, got %v", err)
}
}
func TestParseReflectionNoJSON(t *testing.T) {
if _, err := parseReflection("I cannot help."); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err)
}
}
func TestParseReflectionRequiresRecap(t *testing.T) {
if _, err := parseReflection(`{"recap":" ","carry_forward":"x"}`); !errors.Is(err, ErrInvalidReflection) {
t.Fatalf("blank recap should be invalid, got %v", err)
}
}
func TestParseReflectionAllowsEmptyCarryForward(t *testing.T) {
refl, err := parseReflection(`{"recap":"did the thing","carry_forward":""}`)
if err != nil {
t.Fatalf("carry_forward may be empty: %v", err)
}
if refl.Recap != "did the thing" || refl.CarryForward != "" {
t.Fatalf("bad reflection: %+v", refl)
}
}
+172 -1
View File
@@ -7,6 +7,7 @@ package session
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log" "log"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -58,6 +59,21 @@ const (
knowledgeError = "error" knowledgeError = "error"
) )
const reflectionTimeout = 30 * time.Second
const reflectionHistoryN = 5
// reflectionTopBuckets caps how many per-window time buckets the finished-session
// block lists for the reviewer, keeping the prompt compact.
const reflectionTopBuckets = 3
const (
reflectionIdle = "idle"
reflectionPending = "pending"
reflectionReady = "ready"
reflectionAbsent = "absent"
)
const ( const (
driftDebounce = 10 * time.Second driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second driftTimeout = 30 * time.Second
@@ -125,6 +141,12 @@ type Controller struct {
knowledgeChars int knowledgeChars int
knowledgeGen int knowledgeGen int
reviewer ai.Reviewer
reflectionStatus string
reflectionRecap string
carryForward string // latest-wins takeaway; grounds the next coach
reflectionGen int
allowedClasses []string // durable: the active session's allowed window classes allowedClasses []string // durable: the active session's allowed window classes
judge ai.DriftJudge judge ai.DriftJudge
driftStatus string driftStatus string
@@ -194,6 +216,15 @@ type KnowledgeView struct {
Chars int `json:"chars,omitempty"` Chars int `json:"chars,omitempty"`
} }
// ReflectionView projects the reviewer's output. Recap is shown on Review;
// CarryForward is shown on the next Planning screen. Unlike the knowledge
// profile, these short lines exist to be displayed, so they cross the wire.
type ReflectionView struct {
Status string `json:"status,omitempty"`
Recap string `json:"recap,omitempty"`
CarryForward string `json:"carry_forward,omitempty"`
}
// WindowView / BucketView / EvidenceView are the evidence projection. // WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct { type WindowView struct {
Class string `json:"class"` Class string `json:"class"`
@@ -222,6 +253,7 @@ type State struct {
Coach *CoachView `json:"coach,omitempty"` Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"` Tasks *TasksView `json:"tasks,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"` Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Reflection *ReflectionView `json:"reflection,omitempty"`
Drift *DriftView `json:"drift,omitempty"` Drift *DriftView `json:"drift,omitempty"`
} }
@@ -241,6 +273,9 @@ func New(snapshotPath string) (*Controller, error) {
sessionsDir: filepath.Join(dir, "sessions"), sessionsDir: filepath.Join(dir, "sessions"),
clock: time.Now, clock: time.Now,
outcomePending: s.OutcomePending, outcomePending: s.OutcomePending,
reflectionStatus: s.ReflectionStatus,
reflectionRecap: s.ReflectionRecap,
carryForward: s.CarryForward,
} }
if s.DeadlineUnixSecs > 0 { if s.DeadlineUnixSecs > 0 {
c.deadline = time.Unix(s.DeadlineUnixSecs, 0) c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
@@ -354,6 +389,16 @@ func (c *Controller) stateLocked() State {
} }
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars} st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
} }
if c.carryForward != "" {
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
}
}
if c.runtimeState == domain.RuntimeReview {
rstatus := c.reflectionStatus
if rstatus == "" {
rstatus = reflectionIdle
}
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
} }
if c.runtimeState == domain.RuntimeActive { if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus status := c.driftStatus
@@ -387,6 +432,9 @@ func (c *Controller) persistLocked() error {
snap.SessionID = c.stats.SessionID snap.SessionID = c.stats.SessionID
} }
snap.AllowedWindowClasses = c.allowedClasses snap.AllowedWindowClasses = c.allowedClasses
snap.ReflectionStatus = c.reflectionStatus
snap.ReflectionRecap = c.reflectionRecap
snap.CarryForward = c.carryForward
return store.Save(c.snapshotPath, snap) return store.Save(c.snapshotPath, snap)
} }
@@ -538,6 +586,114 @@ func (c *Controller) startKnowledgeFetchLocked() {
}() }()
} }
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward.
func (c *Controller) SetReviewer(r ai.Reviewer) {
c.mu.Lock()
c.reviewer = r
c.mu.Unlock()
}
// startReflectionFetchLocked kicks off an asynchronous reflection when a
// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the
// completion guard is generation-only (not state-gated): the carry-forward must
// still apply if the user clicks End before the reviewer returns. A superseded
// review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu.
func (c *Controller) startReflectionFetchLocked() {
c.reflectionRecap = ""
c.carryForward = ""
if c.reviewer == nil {
c.reflectionStatus = reflectionIdle
return
}
c.reflectionGen++
gen := c.reflectionGen
c.reflectionStatus = reflectionPending
reviewer := c.reviewer
finished := c.buildReflectionFinishedLocked()
// Read the history synchronously, here under the lock, on purpose: it must
// happen-before End appends the just-finished session to the audit chain, so
// that session is excluded from "recent history" and not double-counted (it
// is already carried in `finished`). Moving this into the goroutine would
// race with End's append and reintroduce that double-count. The read is
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
// on any hot path.
history := buildReflectionHistory(c.auditPath)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), reflectionTimeout)
defer cancel()
refl, err := reviewer.Review(ctx, finished, history)
c.mu.Lock()
if gen != c.reflectionGen {
c.mu.Unlock()
return // superseded review: discard
}
if err != nil || strings.TrimSpace(refl.Recap) == "" {
c.reflectionStatus = reflectionAbsent
c.reflectionRecap = ""
c.carryForward = ""
} else {
c.reflectionStatus = reflectionReady
c.reflectionRecap = refl.Recap
c.carryForward = refl.CarryForward
}
_ = c.persistLocked()
c.mu.Unlock()
c.notify()
}()
}
// buildReflectionFinishedLocked renders the just-finished session as a compact
// block for the reviewer. Caller holds mu; c.stats/c.commitment are still set
// (End clears them, but enterReview runs before End). Reuses bucketViews for the
// per-window totals, already sorted desc by seconds.
func (c *Controller) buildReflectionFinishedLocked() string {
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
}
outcome := c.outcomePending
if outcome == "" {
outcome = "completed"
}
var b strings.Builder
fmt.Fprintf(&b, "Next action: %s\n", na)
fmt.Fprintf(&b, "Success condition: %s\n", sc)
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
if c.stats != nil {
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
for i, bv := range bucketViews(c.stats.Buckets) {
if i >= reflectionTopBuckets {
break
}
fmt.Fprintf(&b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
}
}
return strings.TrimRight(b.String(), "\n")
}
// buildReflectionHistory renders the last few prior sessions as compact lines.
// The just-finished session is not yet in the chain (End appends it), so it is
// not double-counted. Returns "" when there is no usable history.
func buildReflectionHistory(auditPath string) string {
sums, err := store.RecentSessions(auditPath, reflectionHistoryN)
if err != nil || len(sums) == 0 {
return ""
}
var b strings.Builder
for _, s := range sums {
top := ""
if len(s.Buckets) > 0 {
top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60)
}
fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top)
}
return strings.TrimRight(b.String(), "\n")
}
// AllowedClassesForTest exposes the session allowed classes for tests. // AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string { func (c *Controller) AllowedClassesForTest() []string {
c.mu.Lock() c.mu.Lock()
@@ -631,6 +787,20 @@ func (c *Controller) Refocus() error {
return c.persistLocked() return c.persistLocked()
} }
// composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu.
func (c *Controller) composedGroundingLocked() string {
g := c.knowledgeText
if c.carryForward != "" {
if g != "" {
g += "\n\n"
}
g += "Last session's takeaway: " + c.carryForward
}
return g
}
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if // RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach // not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted. // state). The proposal is ephemeral and never persisted.
@@ -654,7 +824,7 @@ func (c *Controller) RequestCoach(intent string) error {
c.coachErr = "" c.coachErr = ""
c.coachProposal = nil c.coachProposal = nil
coach := c.coach coach := c.coach
grounding := c.knowledgeText grounding := c.composedGroundingLocked()
c.mu.Unlock() c.mu.Unlock()
c.notify() c.notify()
@@ -760,6 +930,7 @@ func (c *Controller) enterReview(outcome string) error {
} }
c.runtimeState = next c.runtimeState = next
c.outcomePending = outcome c.outcomePending = outcome
c.startReflectionFetchLocked()
return c.persistLocked() return c.persistLocked()
} }
+152
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -1029,3 +1030,154 @@ func TestCoachReceivesCachedGrounding(t *testing.T) {
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding()) t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
} }
} }
type fakeReviewer struct {
refl ai.Reflection
err error
gate chan struct{} // if non-nil, Review blocks until it receives
calls int32
}
func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) {
atomic.AddInt32(&f.calls, 1)
if f.gate != nil {
<-f.gate
}
return f.refl, f.err
}
// waitReflectionStatus polls until the reflection view reaches want, or fails after 2s.
func waitReflectionStatus(t *testing.T, c *Controller, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
st := c.State()
if st.Reflection != nil && st.Reflection.Status == want {
return st
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("reflection status never reached %q (last: %+v)", want, c.State().Reflection)
return State{}
}
func driveToReview(t *testing.T, c *Controller) {
t.Helper()
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil); err != nil {
t.Fatalf("start: %v", err)
}
if err := c.Complete(); err != nil {
t.Fatalf("complete: %v", err)
}
}
func TestReflectionFetchedOnReview(t *testing.T) {
c, _ := newTestController(t)
rev := &fakeReviewer{refl: ai.Reflection{Recap: "held focus", CarryForward: "start in the editor"}}
c.SetReviewer(rev)
driveToReview(t, c)
st := waitReflectionStatus(t, c, "ready")
if st.Reflection.Recap != "held focus" {
t.Fatalf("recap not projected on Review: %+v", st.Reflection)
}
if n := atomic.LoadInt32(&rev.calls); n != 1 {
t.Fatalf("reviewer should be called exactly once per session, got %d", n)
}
}
func TestNoReviewerYieldsIdleReflection(t *testing.T) {
c, _ := newTestController(t)
driveToReview(t, c)
st := c.State()
if st.Reflection == nil || st.Reflection.Status != "idle" {
t.Fatalf("nil reviewer should yield idle reflection, got %+v", st.Reflection)
}
if err := c.End(); err != nil {
t.Fatalf("End must still work with no reviewer: %v", err)
}
}
func TestCarryForwardGroundsNextCoach(t *testing.T) {
c, _ := newTestController(t)
c.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "begin with the hardest test"}})
fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}}
c.SetCoach(fc)
driveToReview(t, c)
waitReflectionStatus(t, c, "ready")
if err := c.End(); err != nil {
t.Fatalf("end: %v", err)
}
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
if err := c.RequestCoach("ship it"); err != nil {
t.Fatalf("request coach: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && fc.grounding() == "" {
time.Sleep(5 * time.Millisecond)
}
if g := fc.grounding(); !strings.Contains(g, "begin with the hardest test") {
t.Fatalf("carry-forward not threaded into coach grounding: %q", g)
}
}
func TestReflectionStaleResultDiscarded(t *testing.T) {
c, _ := newTestController(t)
slow := &fakeReviewer{refl: ai.Reflection{Recap: "OLD", CarryForward: "old"}, gate: make(chan struct{})}
c.SetReviewer(slow)
driveToReview(t, c) // gen1 pending, goroutine blocks on gate
waitReflectionStatus(t, c, "pending")
_ = c.End() // Review -> Locked; gen1 still blocked
fast := &fakeReviewer{refl: ai.Reflection{Recap: "NEW", CarryForward: "new"}}
c.SetReviewer(fast)
driveToReview(t, c) // gen2 -> ready NEW
st := waitReflectionStatus(t, c, "ready")
if st.Reflection.Recap != "NEW" {
t.Fatalf("expected NEW, got %+v", st.Reflection)
}
close(slow.gate) // release gen1; it must be discarded
time.Sleep(50 * time.Millisecond)
if got := c.State().Reflection.Recap; got != "NEW" {
t.Fatalf("stale gen1 overwrote reflection: %q", got)
}
}
func TestCarryForwardSurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
first.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "lead with tests"}})
if err := first.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
t.Fatalf("start: %v", err)
}
if err := first.Complete(); err != nil {
t.Fatalf("complete: %v", err)
}
waitReflectionStatus(t, first, "ready")
if err := first.End(); err != nil {
t.Fatalf("end: %v", err)
}
second, err := New(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if err := second.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
st := second.State()
if st.Reflection == nil || st.Reflection.CarryForward != "lead with tests" {
t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection)
}
}
+19
View File
@@ -74,6 +74,25 @@ func readSummaries(path string) ([]SessionSummary, error) {
return out, sc.Err() return out, sc.Err()
} }
// RecentSessions returns up to n most-recent summaries from the audit chain in
// oldest-first order. A missing/empty chain or n <= 0 yields (nil, nil).
func RecentSessions(path string, n int) ([]SessionSummary, error) {
if n <= 0 {
return nil, nil
}
all, err := readSummaries(path)
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, nil
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, nil
}
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns // AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
// the next contiguous Seq, computes its Hash, and appends one JSON line with an // the next contiguous Seq, computes its Hash, and appends one JSON line with an
// fsync. // fsync.
+42
View File
@@ -75,3 +75,45 @@ func TestTamperDetected(t *testing.T) {
t.Fatalf("tamper on line 2 should be reported, got %v", err) t.Fatalf("tamper on line 2 should be reported, got %v", err)
} }
} }
func TestRecentSessionsReturnsLastNOldestFirst(t *testing.T) {
path := auditFixture(t)
for _, id := range []string{"a", "b", "c"} {
if err := AppendSession(path, sampleSummary("session-"+id)); err != nil {
t.Fatalf("append %s: %v", id, err)
}
}
got, err := RecentSessions(path, 2)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 summaries, got %d", len(got))
}
if got[0].SessionID != "session-b" || got[1].SessionID != "session-c" {
t.Fatalf("want [b c] oldest-first, got [%s %s]", got[0].SessionID, got[1].SessionID)
}
}
func TestRecentSessionsFewerThanN(t *testing.T) {
path := auditFixture(t)
_ = AppendSession(path, sampleSummary("session-a"))
got, err := RecentSessions(path, 5)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(got) != 1 || got[0].SessionID != "session-a" {
t.Fatalf("want [a], got %+v", got)
}
}
func TestRecentSessionsMissingOrZero(t *testing.T) {
if got, err := RecentSessions(auditFixture(t), 5); err != nil || got != nil {
t.Fatalf("missing chain: want (nil,nil), got (%+v,%v)", got, err)
}
path := auditFixture(t)
_ = AppendSession(path, sampleSummary("session-a"))
if got, err := RecentSessions(path, 0); err != nil || got != nil {
t.Fatalf("n=0: want (nil,nil), got (%+v,%v)", got, err)
}
}
+4
View File
@@ -21,6 +21,10 @@ type Snapshot struct {
OutcomePending string `json:"outcome_pending,omitempty"` OutcomePending string `json:"outcome_pending,omitempty"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
ReflectionStatus string `json:"reflection_status,omitempty"`
ReflectionRecap string `json:"reflection_recap,omitempty"`
CarryForward string `json:"carry_forward,omitempty"`
} }
// DefaultPath returns ~/.antidrift/state.json. // DefaultPath returns ~/.antidrift/state.json.
+1
View File
@@ -119,6 +119,7 @@ input:focus { outline: 0; border-color: var(--accent); }
/* Planning: standing-profile grounding indicator */ /* Planning: standing-profile grounding indicator */
.knowline { opacity: 0.85; } .knowline { opacity: 0.85; }
.reflectline { opacity: 0.85; }
.link { .link {
background: none; border: none; padding: 0; font: inherit; font-size: 12px; background: none; border: none; padding: 0; font: inherit; font-size: 12px;
color: var(--accent); cursor: pointer; text-decoration: underline; color: var(--accent); cursor: pointer; text-decoration: underline;
+25
View File
@@ -179,6 +179,27 @@ function updatePlanningKnowledge(k) {
}; };
} }
// reflectionBlock renders the reviewer's recap on the Review screen. idle/nil or
// an empty recap renders nothing; pending shows a quiet line; ready shows the
// one-line recap.
function reflectionBlock(refl) {
if (!refl) return '';
if (refl.status === 'pending') return `<div class="band"><div class="reflectline meta">reflecting…</div></div>`;
if (refl.status === 'ready' && refl.recap) {
return `<div class="band"><div class="reflectline">${refl.recap}</div></div>`;
}
return '';
}
// updatePlanningReflection renders last session's carry-forward takeaway as a
// quiet one-liner on the planning screen. Nothing renders without one.
function updatePlanningReflection(refl) {
const el = document.getElementById('reflectBand');
if (!el) return;
if (!refl || !refl.carry_forward) { el.innerHTML = ''; return; }
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
}
function render(state) { function render(state) {
setStateAttr(state); setStateAttr(state);
const rs = state.runtime_state; const rs = state.runtime_state;
@@ -186,6 +207,7 @@ function render(state) {
updatePlanningCoach(state.coach); updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge); updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection);
return; return;
} }
if (rs === 'active' && renderedState === 'active') { if (rs === 'active' && renderedState === 'active') {
@@ -213,6 +235,7 @@ function render(state) {
</div> </div>
<div class="band" id="tasksBand"></div> <div class="band" id="tasksBand"></div>
<div class="band" id="knowBand"></div> <div class="band" id="knowBand"></div>
<div class="band" id="reflectBand"></div>
<div class="band"> <div class="band">
<label>Next action</label><input id="na" placeholder="What exactly will you do?"> <label>Next action</label><input id="na" placeholder="What exactly will you do?">
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?"> <label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
@@ -239,6 +262,7 @@ function render(state) {
updatePlanningCoach(state.coach); updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge); updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection);
} else if (rs === 'active') { } else if (rs === 'active') {
const c = state.commitment || {}; const c = state.commitment || {};
@@ -265,6 +289,7 @@ function render(state) {
<p class="meta">${c.next_action || ''}</p> <p class="meta">${c.next_action || ''}</p>
<p class="meta">done when: ${c.success_condition || ''}</p> <p class="meta">done when: ${c.success_condition || ''}</p>
</div> </div>
${reflectionBlock(state.reflection)}
${reviewSummary(state.evidence)} ${reviewSummary(state.evidence)}
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`; <div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
document.getElementById('end').onclick = () => post('/end'); document.getElementById('end').onclick = () => post('/end');
+45
View File
@@ -317,6 +317,51 @@ func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
} }
} }
type stubReviewer struct {
refl ai.Reflection
}
func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) {
return s.refl, nil
}
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
r := s.Router()
_ = post(t, r, "/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
}
js := s.stateJSON()
if !strings.Contains(js, `"recap":"held focus well"`) {
t.Fatalf("review payload missing recap: %s", js)
}
// End -> Locked -> Planning: the carry-forward should surface on planning.
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
js2 := s.stateJSON()
if !strings.Contains(js2, `"carry_forward":"start in the editor"`) {
t.Fatalf("planning payload missing carry-forward: %s", js2)
}
}
func TestKnowledgePathSelectionReloads(t *testing.T) { func TestKnowledgePathSelectionReloads(t *testing.T) {
s := newTestServer(t) s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})