Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
18 KiB
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—EvidenceStatsgains the split fields; newcreditLockedhelper centralizes crediting;applyEventroutes through it. Allocation of the new maps added to bothreplayStatsbranches.internal/session/session.go— allocate the split maps inStartManualCommitment; route the end-of-session flush inenterReviewthroughcreditLocked.internal/session/roles.go—buildReflectionFinishedLockedrenders totals + split top lists; smallwriteBucketList/sumDurationshelpers added alongside it.internal/session/session_test.go— three tests:creditLockedunit mapping, rendering (incl. empty-list omission), and end-to-end credit-time wiring.
Three tasks, each self-contained and committed independently:
- Split accounting — fields, allocation,
creditLocked, route both credit sites. (Driven by thecreditLockedunit test.) - Reflection rendering — rewrite
buildReflectionFinishedLocked. (Driven by a rendering unit test, including empty-off-task omission.) - End-to-end faithfulness — a test-only task proving the live drift status reaches the right bucket through a real
RecordWindowsequence.
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(StartManualCommitmentstats init ~248-252,enterReviewflush ~280-283) -
Test:
internal/session/session_test.go(new test appended) -
Step 1: Write the failing test
Append to internal/session/session_test.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):
// 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
creditLockedand routeapplyEventthrough it
In internal/session/stats.go, change the credit line in applyEvent (currently lines 28-30) and add the helper. The applyEvent body becomes:
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
Bucketsis 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):
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):
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):
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):
// 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
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:
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:
// 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
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).
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
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.