Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package focus
|
||||
|
||||
import (
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
||||
type bucketKey struct{ Class, Title string }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// applyEvent advances stats by one observation: it credits the prior segment to
|
||||
// the prior bucket, counts a context switch on key change, and records the new
|
||||
// current window. Used by both live tracking and crash replay. Caller holds mu.
|
||||
func (c *Mode) 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 *Mode) 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
|
||||
}
|
||||
}
|
||||
|
||||
// replayStats rebuilds in-memory stats from the raw session log after a crash.
|
||||
func (c *Mode) replayStats(sessionID string) {
|
||||
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
||||
if err != nil || len(events) == 0 {
|
||||
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
|
||||
}
|
||||
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{},
|
||||
}
|
||||
// Drift status is not persisted, so replayed pre-crash segments credit to
|
||||
// unclassified (driftStatus is driftIdle here). Totals in Buckets are exact;
|
||||
// only the on/off split degrades — honest, never falsely on-task.
|
||||
for _, e := range events {
|
||||
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
|
||||
}
|
||||
}
|
||||
|
||||
func keyFor(snap evidence.WindowSnapshot) bucketKey {
|
||||
if !snap.Health.Available {
|
||||
return bucketKey{Class: "", Title: unavailableTitle}
|
||||
}
|
||||
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
|
||||
}
|
||||
|
||||
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
|
||||
return store.FocusEvent{
|
||||
AtUnixMillis: now.UnixMilli(),
|
||||
Class: snap.Class,
|
||||
Title: snap.Title,
|
||||
Available: snap.Health.Available,
|
||||
Reason: snap.Health.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
|
||||
return evidence.WindowSnapshot{
|
||||
Title: e.Title,
|
||||
Class: e.Class,
|
||||
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user