Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.8 KiB
Faithful reflection — on/off-task split — design
Date: 2026-06-01 Status: Approved (brainstorming), pending implementation plan
Problem
When a session ends, buildReflectionFinishedLocked (internal/session/roles.go)
hands the AI reviewer the commitment, outcome, context-switch count, and the top
few time buckets as bare class · title: Nm lines. Nothing in that block tells
the reviewer which of those buckets were off-task. If 20 minutes of "firefox"
was doom-scrolling, the reviewer cannot see it and writes a charitable recap.
The daemon already knows, minute to minute, whether the active window is on- or
off-task: evaluateDriftLocked maintains a live driftStatus of ontask /
drifting / idle / pending. But that signal is never attached to the time
buckets — buckets are pure (class, title) → duration. This design carries the
live drift signal into the time accounting so reflection can report on-task vs
off-task honestly.
Decisions (locked during brainstorming)
- Faithful, per-segment classification — tag each slice of time with the
live
driftStatusat the moment it is credited, accumulating on-task / off-task / unclassified durations. Not reconstructed from end-state (which would retro-taint earlier on-task time and is only class-level, not tab-level). - Split top lists in the reflection block — lead with on-task / off-task /
unclassified minute totals, then a top-N on-task list and a top-N off-task
list, each capped at
reflectionTopBuckets. - In-memory only; no log schema change — the split lives in live
EvidenceStats. After a mid-session daemon restart, pre-restart time replays asunclassified(its drift status is no longer known). Honest degrade — never falsely on-task. The on-disk focus log format is unchanged.
Architecture
The key insight: credit-time status is already correct
In RecordWindow (internal/session/drift.go), the call order per observation is:
applyEvent(now, snap) // credits the JUST-ENDED segment to its bucket
recordTitleLocked(...)
evaluateDriftLocked(now, snap) // reclassifies driftStatus for the NEW window
applyEvent credits the segment that just ended before evaluateDriftLocked
reclassifies. So at the instant a segment is credited, c.driftStatus still
holds that segment's classification. We read it directly — no extra
bookkeeping, no separate timeline.
This holds at the only two credit sites:
applyEvent(internal/session/stats.go) — credits the prior segment when a new observation arrives.- The end-of-session flush (
internal/session/session.go:280-281) — credits the final open segment on the way into Review;driftStatusthere is the current (last) window's classification. Correct.
Data structures (internal/session/stats.go)
EvidenceStats gains two per-bucket maps and one scalar. The existing Buckets
map (total time per bucket) is unchanged — it still feeds the live evidence
panel (views.go) and the persisted history summary (store), both untouched.
The split is purely additive.
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration // total 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, total only
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
OnTask and OffTask are keyed per bucket because the reflection block lists
them by name. unclassified is a scalar because it is only ever shown as a total
(no list). The three split maps/scalar are allocated wherever Buckets is
allocated today: StartManualCommitment (session.go:248-251) and both
replayStats branches (stats.go:45-56).
Crediting helper
A single helper centralizes crediting so both credit sites stay in sync and the status→bucket mapping lives in one place:
// creditLocked credits duration d to bucket k: always to the total, and to the
// on/off/unclassified split per the live drift status (the classification of the
// segment being credited). 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
}
}
Status→bucket mapping:
driftStatus |
bucket |
|---|---|
driftOnTask |
OnTask[k] |
driftDrifting |
OffTask[k] |
driftIdle |
unclassified |
driftPending |
unclassified |
applyEvent's credit line and the end-of-session flush both call
creditLocked instead of writing Buckets[...] directly.
Restart degrade: replayStats calls applyEvent while driftStatus is at
its reset value (driftIdle, set by resetDriftLocked), so all replayed
pre-restart time routes to unclassified. Honest — never falsely on-task.
Invariant: for every bucket k, Buckets[k] == OnTask[k] + OffTask[k] + (its unclassified portion). The unclassified portion is not tracked per bucket, only
in aggregate, because it is never displayed per bucket.
Rendering (internal/session/roles.go, buildReflectionFinishedLocked)
Compute the totals by summing the maps; unclassified is the scalar. Reuse the
existing bucketViews (views.go:182), which already sorts a
map[bucketKey]time.Duration descending by seconds, for each top-N list, capped
at reflectionTopBuckets (currently 3). A bucket worked both on- and off-task
(the user refocused) appears in both lists — its on-portion in one, off-portion
in the other. Faithful.
Rendered block (totals always shown; a list is omitted when empty):
Next action: <na>
Success condition: <sc>
Outcome: completed
On-task 35m / Off-task 22m / Unclassified 3m
Context switches: 14
On-task:
- code · roles.go: 30m
- term · go test: 5m
Off-task:
- firefox · r/news: 12m
- discord · #random: 7m
A fully on-task session shows the totals line and the On-task list, and omits the
Off-task: block entirely (and vice versa). The minute values use the same
Seconds/60 integer-minute rendering already used for buckets.
Error handling / degrade
- No drift judge wired: every off-task-candidate segment stays
driftIdle(seeevaluateDriftLockedstep 3), so all such time isunclassifiedrather than off-task. The reflection block then shows on-task + unclassified totals, which is honest: without a judge we genuinely do not know. - Sensor unavailable: the segment is bucketed under the existing
unavailableTitlekey (keyFor,stats.go:62-67) and split per the then-currentdriftStatuslike any other segment. No special case. - Mid-session restart: pre-restart time →
unclassified(above). stats == nil:buildReflectionFinishedLockedalready guardsc.stats != nilbefore rendering buckets; the split rendering sits inside that same guard.
Testing
creditLockedunit test (stats_test.goorsession_test.go): with a fixedbucketKeyand duration, assert eachdriftStatusvalue routes to the correct map/scalar (driftOnTask→OnTask,driftDrifting→OffTask,driftIdle/driftPending→unclassified).- End-to-end reflection block test (
session_test.go): with a fake clock and a fakeai.DriftJudge, driveRecordWindowthrough a sequence where some segments match the allowlist (on-task) and some are judged not-on-task (off-task), then transition to Review and assertbuildReflectionFinishedLockedoutput contains the correctOn-task/Off-task/Unclassifiedminute totals, theOn-task:andOff-task:headers, and the expected named off-task bucket line. - Empty-list omission test: a fully on-task session renders no
Off-task:block.
Files touched
internal/session/stats.go—EvidenceStatssplit fields; allocate them;creditLockedhelper; routeapplyEventthrough it.internal/session/session.go— allocate split maps inStartManualCommitment; route the end-of-session flush throughcreditLocked.internal/session/roles.go—buildReflectionFinishedLockedrenders totals + split top lists.internal/session/session_test.go/stats_test.go— tests above.
Out of scope (YAGNI)
- Persisting the split to the focus log or the audit/history summary (no cross-restart reconstruction; history charitable-ness is a separate concern).
- Per-bucket unclassified breakdown (only the aggregate is shown).
- Any change to the live evidence panel, the drift/nudge pipeline, or the reviewer prompt contract beyond the finished-session block text.
- Surfacing the split in the web UI (this design targets the reviewer input only).