Files
antidrift/docs/superpowers/plans/2026-06-01-m7-reflection.md
T
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

37 KiB
Raw Blame History

M7 — Reflection Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add the fourth AI role — the Reviewer — so that when a session ends the daemon reflects on it (read against recent sessions), shows a one-line recap on the Review screen, and carries a one-line takeaway forward to ground the coach on the next planning cycle.

Architecture: A new leaf ai.Reviewer role (primitives only) mirrors Coach/DriftJudge/Nudge. The session.Controller fetches a reflection asynchronously on entering Review (generation-guarded, non-blocking, graceful), caches the recap, and stores a latest-wins carryForward that rides the existing snapshot and composes into the coach's existing free-form grounding string — so ai.Coach's signature is unchanged. The two short lines are display data, so they cross the wire (unlike the M6 profile). Persistence is snapshot-only; no audit-chain changes.

Tech Stack: Go 1.26, stdlib only (context, encoding/json, fmt, strings); go test for tests; vanilla JS/CSS for the UI.

Design spec: docs/superpowers/specs/2026-06-01-m7-reflection-design.md


File Structure

  • Create internal/ai/reflection.go — the Reflection struct, Reviewer interface, Service.Review, prompt builder, parser.
  • Create internal/ai/reflection_test.go — prompt + parser + service tests.
  • Modify internal/store/audit.go — add exported RecentSessions(path, n).
  • Modify internal/store/audit_test.go — test RecentSessions.
  • Modify internal/store/store.go — add reflection fields to Snapshot.
  • Modify internal/session/session.go — reviewer field/constants, SetReviewer, startReflectionFetchLocked, enterReview hook, projection (Review + Planning), snapshot persist/restore, grounding composition in RequestCoach.
  • Modify internal/session/session_test.go — reviewer fakes + reflection tests.
  • Modify cmd/antidriftd/main.go — wire the reviewer (ctrl.SetReviewer).
  • Modify internal/web/web_test.go — assert the reflection crosses the wire.
  • Modify internal/web/static/app.js — render recap on Review, carry-forward on Planning.
  • Modify internal/web/static/app.css — one styling line.
  • Modify README.md — M7 Status paragraph.

Note on intermediate build state: No task changes an existing exported signature, so the tree builds green after every task. (M7 adds to the ai/store/session APIs; it changes none.)


Task 1: The ai.Reviewer role

Files:

  • Create: internal/ai/reflection.go
  • Test: internal/ai/reflection_test.go

The ai package is a leaf: it takes only primitive strings. The controller (Task 3) builds the finished and history strings. The existing fakeBackend (in coach_test.go, same package) is reused by the tests here — do not redefine it.

  • Step 1: Write the failing tests

Create internal/ai/reflection_test.go:

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)
	}
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/ai/ -run Refl -v Expected: build failure — undefined: parseReflection, undefined: ErrInvalidReflection, Review not a method of *Service.

  • Step 3: Write the implementation

Create internal/ai/reflection.go:

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
}
  • Step 4: Run the tests to verify they pass

Run: go test ./internal/ai/ -v Expected: PASS (all existing ai tests plus the new reflection tests).

  • Step 5: Commit
git add internal/ai/reflection.go internal/ai/reflection_test.go
git commit -m "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>"

Task 2: store.RecentSessions

Files:

  • Modify: internal/store/audit.go
  • Test: internal/store/audit_test.go

readSummaries is unexported. Add a bounded, exported reader returning the last n summaries in oldest-first order. The controller formats these into the history string.

  • Step 1: Write the failing test

Append to internal/store/audit_test.go:

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)
	}
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/store/ -run RecentSessions -v Expected: build failure — undefined: RecentSessions.

  • Step 3: Write the implementation

Add to internal/store/audit.go (after the readSummaries function):

// 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
}
  • Step 4: Run the tests to verify they pass

Run: go test ./internal/store/ -v Expected: PASS.

  • Step 5: Commit
git add internal/store/audit.go internal/store/audit_test.go
git commit -m "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>"

Task 3: Controller reflection wiring

Files:

  • Modify: internal/store/store.go (snapshot fields)
  • Modify: internal/session/session.go
  • Test: internal/session/session_test.go

This is the integration task: the reviewer is fetched on entering Review, the result is cached and rides the snapshot, the carryForward composes into the coach's grounding, and the projection exposes a recap (Review) and carry-forward (Planning). The fetch's generation guard is generation-only (no runtime-state gate) because the carryForward must still apply if the user clicks End before the reviewer returns — only a superseded review should be discarded.

  • Step 1: Add the snapshot fields

In internal/store/store.go, extend the Snapshot struct (add the three fields after AllowedWindowClasses):

	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"`
}

Run: go build ./internal/store/ Expected: builds clean.

  • Step 2: Write the failing controller tests

Append to internal/session/session_test.go. First add "strings" to the import block (it is not yet imported), placing it after "sync/atomic":

	"strings"
	"sync"
	"sync/atomic"

Then append the fakes, helpers, and tests:

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)
	c.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "held focus", CarryForward: "start in the editor"}})
	driveToReview(t, c)
	st := waitReflectionStatus(t, c, "ready")
	if st.Reflection.Recap != "held focus" {
		t.Fatalf("recap not projected on Review: %+v", st.Reflection)
	}
}

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)
	}
}
  • Step 3: Run the tests to verify they fail

Run: go test ./internal/session/ -run 'Reflection|CarryForward|Reviewer' -v Expected: build failure — undefined: (*Controller).SetReviewer, st.Reflection undefined.

  • Step 4: Add reflection constants

In internal/session/session.go, add "fmt" to the import block (after "errors"):

	"context"
	"errors"
	"fmt"
	"log"

Then add the constants after the knowledge const block (after line ~59):

const reflectionTimeout = 30 * time.Second

const reflectionHistoryN = 5

const (
	reflectionIdle    = "idle"
	reflectionPending = "pending"
	reflectionReady   = "ready"
	reflectionAbsent  = "absent"
)
  • Step 5: Add controller fields

In the Controller struct, add a reflection block after the knowledge block (after knowledgeGen int):

	reviewer         ai.Reviewer
	reflectionStatus string
	reflectionRecap  string
	carryForward     string // latest-wins takeaway; grounds the next coach
	reflectionGen    int
  • Step 6: Add the ReflectionView and the State field

After the KnowledgeView type, add:

// 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"`
}

In the State struct, add the field after Knowledge:

	Knowledge    *KnowledgeView      `json:"knowledge,omitempty"`
	Reflection   *ReflectionView     `json:"reflection,omitempty"`
	Drift        *DriftView          `json:"drift,omitempty"`
  • Step 7: Restore reflection fields in New

In New, extend the Controller literal (after outcomePending: s.OutcomePending,):

		clock:          time.Now,
		outcomePending: s.OutcomePending,
		reflectionStatus: s.ReflectionStatus,
		reflectionRecap:  s.ReflectionRecap,
		carryForward:     s.CarryForward,
	}
  • Step 8: Persist reflection fields in persistLocked

In persistLocked, after 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)
  • Step 9: Project reflection in stateLocked

Inside stateLocked, in the if c.runtimeState == domain.RuntimePlanning { block, after the knowledge projection (st.Knowledge = ... closing brace), add:

		if c.carryForward != "" {
			st.Reflection = &ReflectionView{CarryForward: c.carryForward}
		}
	}

Then, after that planning block closes (before if c.runtimeState == domain.RuntimeActive {), add a Review block:

	if c.runtimeState == domain.RuntimeReview {
		rstatus := c.reflectionStatus
		if rstatus == "" {
			rstatus = reflectionIdle
		}
		st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
	}
  • Step 10: Add SetReviewer, startReflectionFetchLocked, and the input builders

After the SetKnowledge/SetKnowledgePath/startKnowledgeFetchLocked group (after startKnowledgeFetchLocked closes, ~line 539), add:

// 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()
	history := buildReflectionHistory(c.auditPath) // small, bounded file read
	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 >= 3 {
				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")
}
  • Step 11: Fire the fetch on entering Review

In enterReview, insert the fetch after c.outcomePending = outcome and before the final return c.persistLocked():

	c.runtimeState = next
	c.outcomePending = outcome
	c.startReflectionFetchLocked()
	return c.persistLocked()
  • Step 12: Compose the carry-forward into the coach grounding

Add a helper near RequestCoach:

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

Then in RequestCoach, replace the line grounding := c.knowledgeText with:

	grounding := c.composedGroundingLocked()
  • Step 13: Run the tests to verify they pass

Run: go test ./internal/session/ -run 'Reflection|CarryForward|Reviewer' -v Expected: PASS (5 new tests).

  • Step 14: Run the full session + store + ai suites with the race detector

Run: go test -race ./internal/session/ ./internal/store/ ./internal/ai/ Expected: PASS. (TestCoachReceivesCachedGrounding still passes: with no reviewer set, carryForward is "" and the composed grounding equals the profile text exactly.)

  • Step 15: Commit
git add internal/store/store.go internal/session/session.go internal/session/session_test.go
git commit -m "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>"

Task 4: Daemon wiring + web payload test

Files:

  • Modify: cmd/antidriftd/main.go

  • Test: internal/web/web_test.go

  • Step 1: Write the failing web test

Append to internal/web/web_test.go:

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)
	}
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/web/ -run Reflection -v Expected: build failure — s.ctrl.SetReviewer undefined is already resolved by Task 3, so the failure here is the missing daemon wiring is not what this tests; the test should actually compile and PASS already (the controller wiring from Task 3 is enough). If it PASSES, that is expected — proceed to Step 3 to add the daemon wiring (which the test does not exercise). If it FAILS to compile, re-check Task 3 was applied.

Rationale: the web payload is produced by the controller, which Task 3 already wired. This test guards the wire format. The daemon change in Step 3 is the production wiring (main.go) that no test exercises, mirroring how the M6 tasks/knowledge adapters are wired.

  • Step 3: Wire the reviewer in main.go

In cmd/antidriftd/main.go, in the AI block, add ctrl.SetReviewer(svc) alongside the other roles and update the log line:

		svc := ai.NewService(backend)
		ctrl.SetCoach(svc)
		ctrl.SetDriftJudge(svc)
		ctrl.SetNudge(svc)
		ctrl.SetReviewer(svc)
		log.Printf("ai: %s backend (coach + drift judge + nudge + reviewer)", backend.Name())
  • Step 4: Run the test + build to verify

Run: go test ./internal/web/ -run Reflection -v && go build ./... Expected: test PASS; build clean.

  • Step 5: Commit
git add cmd/antidriftd/main.go internal/web/web_test.go
git commit -m "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>"

Task 5: Planning + Review UI

Files:

  • Modify: internal/web/static/app.js
  • Modify: internal/web/static/app.css
  • Modify: README.md

No JS test harness exists in this repo (consistent with M2M6); this task is verified by go build/go vet and a human visual check. Keep the changes small and presentational.

  • Step 1: Add the two render helpers

In internal/web/static/app.js, after updatePlanningKnowledge (ends ~line 180), add:

// 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>`;
}
  • Step 2: Call the planning helper in both planning render paths

In render, the incremental planning branch currently reads:

  if (rs === 'planning' && renderedState === 'planning') {
    updatePlanningCoach(state.coach);
    updatePlanningTasks(state.tasks);
    updatePlanningKnowledge(state.knowledge);
    return;
  }

Add the reflection call:

  if (rs === 'planning' && renderedState === 'planning') {
    updatePlanningCoach(state.coach);
    updatePlanningTasks(state.tasks);
    updatePlanningKnowledge(state.knowledge);
    updatePlanningReflection(state.reflection);
    return;
  }

In the full planning render (the } else if (rs === 'planning') { block), add the reflectBand div after knowBand:

      <div class="band" id="tasksBand"></div>
      <div class="band" id="knowBand"></div>
      <div class="band" id="reflectBand"></div>

and add the call alongside the other planning updaters at the end of that block:

    updatePlanningCoach(state.coach);
    updatePlanningTasks(state.tasks);
    updatePlanningKnowledge(state.knowledge);
    updatePlanningReflection(state.reflection);
  • Step 3: Render the recap on the Review screen

In the } else if (rs === 'review') { block, insert reflectionBlock before reviewSummary:

      ${reflectionBlock(state.reflection)}
      ${reviewSummary(state.evidence)}
  • Step 4: Add the styling line

In internal/web/static/app.css, after the .knowline { opacity: 0.85; } line, add:

.reflectline { opacity: 0.85; }
  • Step 5: Update the README Status section

In README.md, add a new paragraph at the top of the ## Status section (above the M6 paragraph):

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.
  • Step 6: Verify build and vet

Run: go build ./... && go vet ./... && go test ./... Expected: all PASS.

  • Step 7: Commit
git add internal/web/static/app.js internal/web/static/app.css README.md
git commit -m "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>"

Final Verification

After all tasks, confirm the milestone holds end-to-end:

  • go build ./... — clean.
  • go vet ./... — clean.
  • go test -race ./... — all packages PASS.
  • Spec invariants:
    • ai.Coach's signature is unchanged (grep: func.*Coach(ctx context.Context, intent, grounding string) still the only Coach signature).
    • No new file or format under ~/.antidrift beyond snapshot fields (grep audit.go for no new write paths; RecentSessions is read-only).
    • The reflection recap/carry-forward appear in State JSON; the knowledge profile text still does not (TestPlanningStatePayloadCarriesKnowledge still passes).
    • End is never blocked on the reviewer (TestNoReviewerYieldsIdleReflection and the stale-discard test cover the fast-End path).

Self-Review Notes

  • Spec coverage: new role (Task 1) ✓; recent-history input via RecentSessions (Task 2) ✓; fetch-on-Review + generation guard + graceful + non-blocking + snapshot persistence + grounding composition + projection (Task 3) ✓; daemon wiring + wire-format (Task 4) ✓; Review recap + Planning carry-forward UI + README (Task 5) ✓; "out of scope" items (no reflections.jsonl, no Coach signature change, no session.go refactor) are respected.
  • Type consistency: Reflection{Recap, CarryForward}, ReflectionView{Status, Recap, CarryForward}, Snapshot.{ReflectionStatus, ReflectionRecap, CarryForward}, and controller fields reflectionStatus/reflectionRecap/carryForward/reflectionGen/reviewer are used identically across tasks. JSON keys recap/carry_forward/status match between the view tags and the JS/web assertions.
  • Generation guard difference: documented in Task 3 Step 10 — generation-only (not state-gated) so the carry-forward applies even after a fast End, unlike tasks/knowledge which gate on RuntimePlanning.