# Faithful Reflection (on/off-task split) 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:** Make the session-end reflection block report how much time was on-task vs off-task, so the AI reviewer stops writing charitable recaps. **Architecture:** Each window segment is classified at the moment it is credited, reading the live `driftStatus` that is already correct at that instant (in `RecordWindow`, `applyEvent` credits the just-ended segment *before* `evaluateDriftLocked` reclassifies for the new window). The split is accumulated in two new per-bucket maps plus one scalar on the in-memory `EvidenceStats`; the existing `Buckets` total map is untouched. `buildReflectionFinishedLocked` then renders on/off/unclassified totals plus a top-N on-task list and a top-N off-task list. **Tech Stack:** Go, standard library only. Tests use the existing `testing` package with the package-local `fakeClock`, `fakeJudge`, `snap`/`obs`, `startActive`, and `waitDriftStatus` helpers in `internal/session/session_test.go`. **Spec:** `docs/superpowers/specs/2026-06-01-faithful-reflection-design.md` --- ## File Structure - `internal/session/stats.go` — `EvidenceStats` gains the split fields; new `creditLocked` helper centralizes crediting; `applyEvent` routes through it. Allocation of the new maps added to both `replayStats` branches. - `internal/session/session.go` — allocate the split maps in `StartManualCommitment`; route the end-of-session flush in `enterReview` through `creditLocked`. - `internal/session/roles.go` — `buildReflectionFinishedLocked` renders totals + split top lists; small `writeBucketList` / `sumDurations` helpers added alongside it. - `internal/session/session_test.go` — three tests: `creditLocked` unit mapping, rendering (incl. empty-list omission), and end-to-end credit-time wiring. Three tasks, each self-contained and committed independently: 1. **Split accounting** — fields, allocation, `creditLocked`, route both credit sites. (Driven by the `creditLocked` unit test.) 2. **Reflection rendering** — rewrite `buildReflectionFinishedLocked`. (Driven by a rendering unit test, including empty-off-task omission.) 3. **End-to-end faithfulness** — a test-only task proving the live drift status reaches the right bucket through a real `RecordWindow` sequence. --- ## Task 1: Split accounting in EvidenceStats Add the on/off/unclassified accumulators and a single crediting helper, then route the two existing credit sites through it. The existing `Buckets` total map is preserved unchanged (it still feeds the live evidence panel and the persisted history summary). **Files:** - Modify: `internal/session/stats.go` (struct fields ~13-22, `applyEvent` ~27-39, `replayStats` ~42-60) - Modify: `internal/session/session.go` (`StartManualCommitment` stats init ~248-252, `enterReview` flush ~280-283) - Test: `internal/session/session_test.go` (new test appended) - [ ] **Step 1: Write the failing test** Append to `internal/session/session_test.go`: ```go func TestCreditLockedSplitsByDriftStatus(t *testing.T) { c, _ := newTestController(t) c.stats = &EvidenceStats{ Buckets: map[bucketKey]time.Duration{}, OnTask: map[bucketKey]time.Duration{}, OffTask: map[bucketKey]time.Duration{}, } k := bucketKey{Class: "code", Title: "main.go"} c.driftStatus = driftOnTask c.creditLocked(k, 10*time.Second) c.driftStatus = driftDrifting c.creditLocked(k, 5*time.Second) c.driftStatus = driftIdle c.creditLocked(k, 3*time.Second) c.driftStatus = driftPending c.creditLocked(k, 2*time.Second) if got := c.stats.OnTask[k]; got != 10*time.Second { t.Errorf("OnTask = %v, want 10s", got) } if got := c.stats.OffTask[k]; got != 5*time.Second { t.Errorf("OffTask = %v, want 5s", got) } if got := c.stats.unclassified; got != 5*time.Second { // 3s idle + 2s pending t.Errorf("unclassified = %v, want 5s", got) } if got := c.stats.Buckets[k]; got != 20*time.Second { // total of all four t.Errorf("Buckets total = %v, want 20s", got) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/session/ -run TestCreditLockedSplitsByDriftStatus` Expected: FAIL — compile error: `c.stats.OnTask` / `c.stats.OffTask` / `c.stats.unclassified` undefined and `c.creditLocked` undefined. - [ ] **Step 3: Add the split fields to `EvidenceStats`** In `internal/session/stats.go`, replace the struct (currently lines 12-22): ```go // EvidenceStats is the in-memory accounting for the current session only. type EvidenceStats struct { SessionID string StartedUnix int64 Buckets map[bucketKey]time.Duration // total time per bucket (unchanged) OnTask map[bucketKey]time.Duration // on-task portion per bucket OffTask map[bucketKey]time.Duration // off-task portion per bucket unclassified time.Duration // idle/pending time; aggregate only SwitchCount int Current evidence.WindowSnapshot lastFocusAt time.Time lastKey bucketKey hasLast bool } ``` - [ ] **Step 4: Add `creditLocked` and route `applyEvent` through it** In `internal/session/stats.go`, change the credit line in `applyEvent` (currently lines 28-30) and add the helper. The `applyEvent` body becomes: ```go func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) { if c.stats.hasLast { c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt)) } newKey := keyFor(snap) if c.stats.hasLast && newKey != c.stats.lastKey { c.stats.SwitchCount++ } c.stats.lastKey = newKey c.stats.lastFocusAt = now c.stats.hasLast = true c.stats.Current = snap } // creditLocked credits duration d to bucket k: always to the running total, and // to the on/off/unclassified split per the live drift status — which, at every // credit site, is the classification of the segment being credited (applyEvent // runs before evaluateDriftLocked reclassifies; the end-of-session flush runs // before the state transition). idle/pending route to unclassified: honest, never // falsely on-task. Caller holds mu. func (c *Controller) creditLocked(k bucketKey, d time.Duration) { c.stats.Buckets[k] += d switch c.driftStatus { case driftOnTask: c.stats.OnTask[k] += d case driftDrifting: c.stats.OffTask[k] += d default: // driftIdle, driftPending c.stats.unclassified += d } } ``` - [ ] **Step 5: Allocate the split maps everywhere `Buckets` is allocated** There are three allocation sites. The maps must be non-nil before `creditLocked` runs. In `internal/session/session.go`, `StartManualCommitment` (currently lines 248-252): ```go c.stats = &EvidenceStats{ SessionID: sessionID, StartedUnix: now.Unix(), Buckets: map[bucketKey]time.Duration{}, OnTask: map[bucketKey]time.Duration{}, OffTask: map[bucketKey]time.Duration{}, } ``` In `internal/session/stats.go`, `replayStats` — the empty/error branch (currently lines 45-50): ```go c.stats = &EvidenceStats{ SessionID: sessionID, StartedUnix: c.clock().Unix(), Buckets: map[bucketKey]time.Duration{}, OnTask: map[bucketKey]time.Duration{}, OffTask: map[bucketKey]time.Duration{}, } return ``` and the replay branch (currently lines 52-56): ```go c.stats = &EvidenceStats{ SessionID: sessionID, StartedUnix: events[0].AtUnixMillis / 1000, Buckets: map[bucketKey]time.Duration{}, OnTask: map[bucketKey]time.Duration{}, OffTask: map[bucketKey]time.Duration{}, } ``` - [ ] **Step 6: Route the end-of-session flush through `creditLocked`** In `internal/session/session.go`, `enterReview` flush (currently lines 280-283): ```go // Flush the final open segment, then freeze accounting. if c.stats != nil && c.stats.hasLast { c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt)) c.stats.hasLast = false } ``` - [ ] **Step 7: Run the new test and the full session suite** Run: `go test ./internal/session/ -run TestCreditLockedSplitsByDriftStatus` Expected: PASS Run: `go test ./internal/session/` Expected: PASS — all existing tests (bucket totals, crash replay, audit summary) unaffected, since `Buckets` is unchanged. - [ ] **Step 8: Commit** ```bash git add internal/session/stats.go internal/session/session.go internal/session/session_test.go git commit -m "Split session time into on/off/unclassified buckets" ``` --- ## Task 2: Render the split in the reflection block Rewrite `buildReflectionFinishedLocked` to emit on/off/unclassified totals followed by a top-N on-task list and a top-N off-task list, reusing the existing `bucketViews` sorter. An empty list (and its label) is omitted. **Files:** - Modify: `internal/session/roles.go` (`buildReflectionFinishedLocked` ~269-292) - Test: `internal/session/session_test.go` (new test appended) - [ ] **Step 1: Write the failing test** Append to `internal/session/session_test.go`. This test sets the split maps directly on a started session, so it is deterministic and independent of drift timing: ```go func TestReflectionBlockShowsOnOffSplit(t *testing.T) { c, _ := newTestController(t) startActive(t, c, nil) // sets commitment ("write report" / "report drafted") and stats c.stats.SwitchCount = 2 c.stats.OnTask = map[bucketKey]time.Duration{ {Class: "code", Title: "main.go"}: 20 * time.Minute, } c.stats.OffTask = map[bucketKey]time.Duration{ {Class: "firefox", Title: "YouTube"}: 8 * time.Minute, {Class: "firefox", Title: "Reddit"}: 4 * time.Minute, } c.stats.unclassified = 5 * time.Minute c.outcomePending = "completed" c.mu.Lock() got := c.buildReflectionFinishedLocked() c.mu.Unlock() want := "Next action: write report\n" + "Success condition: report drafted\n" + "Outcome: completed\n" + "On-task 20m / Off-task 12m / Unclassified 5m\n" + "Context switches: 2\n" + "On-task:\n" + "- code · main.go: 20m\n" + "Off-task:\n" + "- firefox · YouTube: 8m\n" + "- firefox · Reddit: 4m" if got != want { t.Errorf("reflection block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) } } func TestReflectionBlockOmitsEmptyOffTaskList(t *testing.T) { c, _ := newTestController(t) startActive(t, c, nil) c.stats.OnTask = map[bucketKey]time.Duration{ {Class: "code", Title: "main.go"}: 20 * time.Minute, } c.stats.OffTask = map[bucketKey]time.Duration{} // none c.stats.unclassified = 0 c.outcomePending = "completed" c.mu.Lock() got := c.buildReflectionFinishedLocked() c.mu.Unlock() if strings.Contains(got, "Off-task:") { t.Errorf("fully on-task session must omit the Off-task list, got:\n%s", got) } if !strings.Contains(got, "On-task 20m / Off-task 0m / Unclassified 0m") { t.Errorf("totals line missing or wrong, got:\n%s", got) } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `go test ./internal/session/ -run TestReflectionBlock` Expected: FAIL — the current block renders top buckets from `c.stats.Buckets` (e.g. lines like `- · : 0m`) and has no `On-task NNm / Off-task NNm` totals line, so the string comparison fails and `Off-task:` is absent for the wrong reason in the second test (verify the first test fails on the totals line). - [ ] **Step 3: Rewrite `buildReflectionFinishedLocked`** In `internal/session/roles.go`, replace the function (currently lines 265-292) with: ```go // buildReflectionFinishedLocked renders the just-finished session as a compact // block for the reviewer: the commitment, the outcome, on/off/unclassified time // totals, and a top-N on-task list and top-N off-task list. The split fields are // populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still // set (End clears them, but enterReview runs before End). 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 { onMin := int64(sumDurations(c.stats.OnTask).Seconds()) / 60 offMin := int64(sumDurations(c.stats.OffTask).Seconds()) / 60 unclMin := int64(c.stats.unclassified.Seconds()) / 60 fmt.Fprintf(&b, "On-task %dm / Off-task %dm / Unclassified %dm\n", onMin, offMin, unclMin) fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount) writeBucketList(&b, "On-task", c.stats.OnTask) writeBucketList(&b, "Off-task", c.stats.OffTask) } return strings.TrimRight(b.String(), "\n") } // writeBucketList renders a labeled, time-descending list of buckets capped at // reflectionTopBuckets. It writes nothing — not even the label — when the map is // empty, so a single-sided session shows only the list that has time. func writeBucketList(b *strings.Builder, label string, m map[bucketKey]time.Duration) { views := bucketViews(m) if len(views) == 0 { return } fmt.Fprintf(b, "%s:\n", label) for i, bv := range views { if i >= reflectionTopBuckets { break } fmt.Fprintf(b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60) } } // sumDurations totals the durations in a bucket map. func sumDurations(m map[bucketKey]time.Duration) time.Duration { var total time.Duration for _, d := range m { total += d } return total } ``` Note: `bucketViews` (in `views.go`) already returns a `[]BucketView` sorted descending by seconds, so it serves both lists. The old loop over `bucketViews(c.stats.Buckets)` is gone; `c.stats.Buckets` is no longer read here (it remains in use by `views.go` and the audit summary). - [ ] **Step 4: Run the tests to verify they pass** Run: `go test ./internal/session/ -run TestReflectionBlock` Expected: PASS Run: `go test ./internal/session/` Expected: PASS — the existing reflection tests (`TestReflectionFetchedOnReview` etc.) assert on the reviewer's `Recap`, not the finished-block text, so they are unaffected. - [ ] **Step 5: Commit** ```bash git add internal/session/roles.go internal/session/session_test.go git commit -m "Render on/off-task split in the reflection block" ``` --- ## Task 3: End-to-end faithfulness through RecordWindow A test-only task: prove that the live drift status produced by the real drift pipeline reaches the correct split bucket. This is the crux of "faithful" — it exercises the credit-time ordering (`applyEvent` before `evaluateDriftLocked`) and the async drift judge, which the direct unit tests in Tasks 1-2 deliberately bypass. **Files:** - Test: `internal/session/session_test.go` (new test appended) - [ ] **Step 1: Write the test** The sequence: the seed segment accrues while `idle` (unclassified, because the seed never runs the drift pipeline), then on-task code time accrues via the local allowlist match, then off-task firefox time accrues after the async judge returns drifting. `waitDriftStatus` ensures the verdict has landed before the segment that follows is credited, so the classification is deterministic despite the async judge. Durations are in minutes so the integer-minute rendering is exact and the two off-task buckets differ (no sort tie). ```go func TestRecordWindowCreditsSplitFaithfully(t *testing.T) { c, _ := newTestController(t) clk := &fakeClock{now: time.Unix(1000, 0)} c.SetClock(clk.fn()) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}}) c.RecordWindow(snap("code", "main.go")) // latest window before start startActive(t, c, []string{"code"}) // seeds code/main.go at t=1000, driftStatus idle clk.advance(5 * time.Minute) c.RecordWindow(snap("code", "main.go")) // credits 5m to code/main.go while idle -> unclassified; now on-task (local match) clk.advance(10 * time.Minute) c.RecordWindow(snap("code", "main.go")) // credits 10m on-task clk.advance(10 * time.Minute) c.RecordWindow(snap("firefox", "YouTube")) // credits 10m on-task (total 20m); firefox -> judge (pending) waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment clk.advance(8 * time.Minute) c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting clk.advance(4 * time.Minute) if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task t.Fatalf("complete: %v", err) } c.mu.Lock() got := c.buildReflectionFinishedLocked() c.mu.Unlock() want := "Next action: write report\n" + "Success condition: report drafted\n" + "Outcome: completed\n" + "On-task 20m / Off-task 12m / Unclassified 5m\n" + "Context switches: 2\n" + "On-task:\n" + "- code · main.go: 20m\n" + "Off-task:\n" + "- firefox · YouTube: 8m\n" + "- firefox · Reddit: 4m" if got != want { t.Errorf("faithful split mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) } } ``` - [ ] **Step 2: Run the test** Run: `go test ./internal/session/ -run TestRecordWindowCreditsSplitFaithfully` Expected: PASS — Tasks 1 and 2 already supply the behavior; this test asserts the wiring end-to-end. If it FAILS on the `Unclassified 5m` portion, that confirms the credit-time ordering is being read correctly (the seed segment is genuinely idle); do not "fix" it by pre-classifying the seed — that unclassified slice is the honest result and is asserted on purpose. - [ ] **Step 3: Run the full suite with the race detector** Run: `go test -race ./internal/session/` Expected: PASS, no race warnings. (The test spawns the real drift-judge goroutine; `waitDriftStatus` synchronizes before the next credit, and `buildReflectionFinishedLocked` is read under `c.mu`.) - [ ] **Step 4: Commit** ```bash git add internal/session/session_test.go git commit -m "Test faithful on/off-task crediting through RecordWindow" ``` --- ## Final verification After all three tasks: - [ ] Run: `go build ./...` — Expected: success. - [ ] Run: `go vet ./...` — Expected: no diagnostics. - [ ] Run: `go test ./...` — Expected: all packages PASS. ## Out of scope (do not implement) - Persisting the split to the focus log or audit/history summary (no cross-restart reconstruction). - Per-bucket unclassified breakdown (only the aggregate is shown). - Any change to the live evidence panel (`views.go`), the drift/nudge pipeline, the reviewer prompt contract beyond the finished-block text, or the web UI.