Files
felixm 7d69a1f320 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>
2026-06-05 18:00:49 -04:00

359 lines
11 KiB
Go

// Package focus owns the daemon's in-memory state of truth and persists a
// snapshot on every change. Transitions go through the pure statemachine. It
// also owns per-session evidence stats: it accumulates active-window time while
// Active, logs raw focus events, and seals each session into the audit chain.
package focus
import (
"errors"
"log"
"path/filepath"
"sort"
"sync"
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/mode/focus/statemachine"
"keel/internal/store"
"keel/internal/tasks"
"github.com/google/uuid"
)
const (
unavailableTitle = "(evidence unavailable)"
sessionRetention = 30 * 24 * time.Hour
)
var ErrNotPlanning = errors.New("focus: coaching is only available while planning")
var ErrNotActive = errors.New("focus: only available while a commitment is active")
// Mode holds runtime state and the active commitment behind a mutex.
type Mode struct {
mu sync.Mutex
async harness.Async
runtimeState domain.RuntimeState
commitment *domain.Commitment
deadline time.Time
snapshotPath string
auditPath string
sessionsDir string
clock func() time.Time
onChanges []func()
latestWindow evidence.WindowSnapshot
stats *EvidenceStats
outcomePending string
coach ai.Coach
coachStatus string
coachProposal *ai.Proposal
coachErr string
coachGen int
tasksProvider tasks.Provider
tasksStatus string
tasksList []tasks.Task
tasksGen int
knowledgeSrc knowledge.Source
knowledgeStatus string
knowledgeText string // cached grounding the coach reads
knowledgePath string // selected path; "" = adapter default
knowledgeChars int
knowledgeGen int
reviewer ai.Reviewer
reflectionStatus string
reflectionRecap string
carryForward string // latest-wins takeaway; grounds the next coach
reflectionGen int
allowedClasses []string // durable: the active session's allowed window classes
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
guard enforce.Guard
judge ai.DriftJudge
driftStatus string
driftReason string
driftGen int
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
lastJudgedAt time.Time
judgedWindows map[string]ai.Verdict
nudge ai.Nudger
recentTitles []string // in-memory ring of recent distinct titles this session
nudgeMessage string // current soft advisory ("" = none)
lastNudgedAt time.Time
}
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
// in-memory stats from the raw log if a live session was interrupted.
func New(snapshotPath string) (*Mode, error) {
s, err := store.Load(snapshotPath)
if err != nil {
return nil, err
}
dir := filepath.Dir(snapshotPath)
c := &Mode{
runtimeState: s.RuntimeState,
commitment: s.Commitment,
snapshotPath: snapshotPath,
auditPath: filepath.Join(dir, "audit.jsonl"),
sessionsDir: filepath.Join(dir, "sessions"),
clock: time.Now,
outcomePending: s.OutcomePending,
reflectionStatus: s.ReflectionStatus,
reflectionRecap: s.ReflectionRecap,
carryForward: s.CarryForward,
}
if s.DeadlineUnixSecs > 0 {
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
}
if c.runtimeState == "" {
c.runtimeState = domain.RuntimeLocked
}
c.async = harness.NewAsync(&c.mu, c.notify)
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
c.allowedClasses = s.AllowedWindowClasses
c.enforcementLevel = s.EnforcementLevel
// Drift state is not persisted: recompute fresh after restart to avoid
// stale interrupts. This also initializes judgedWindows (the pipeline
// writes to it) so a restored Active session never panics on a nil map.
c.resetDriftLocked()
c.replayStats(s.SessionID)
}
return c, nil
}
// SetClock overrides the time source (tests only). Call before starting a
// session.
func (c *Mode) SetClock(f func() time.Time) {
c.mu.Lock()
c.clock = f
c.mu.Unlock()
}
// SetOnChange registers the sole change listener, replacing any already set. A
// listener is fired after a state change (focus updates, role results) with the
// mutex released. Use AddOnChange to register additional listeners.
func (c *Mode) SetOnChange(f func()) {
c.mu.Lock()
c.onChanges = []func(){f}
c.mu.Unlock()
}
// AddOnChange registers an additional change listener alongside any already set,
// so several consumers (the web broadcaster, the status-file writer) all react
// to the same notification rather than only the last one wired.
func (c *Mode) AddOnChange(f func()) {
c.mu.Lock()
c.onChanges = append(c.onChanges, f)
c.mu.Unlock()
}
func (c *Mode) notify() {
c.mu.Lock()
fs := append([]func(){}, c.onChanges...)
c.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// State returns the current broadcastable state. Safe for concurrent use.
func (c *Mode) State() State {
c.mu.Lock()
defer c.mu.Unlock()
return c.stateLocked()
}
// Deadline returns the active commitment deadline, or the zero time.
func (c *Mode) Deadline() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.deadline
}
func (c *Mode) persistLocked() error {
snap := store.Snapshot{
RuntimeState: c.runtimeState,
Commitment: c.commitment,
OutcomePending: c.outcomePending,
}
if !c.deadline.IsZero() {
snap.DeadlineUnixSecs = c.deadline.Unix()
}
if c.stats != nil {
snap.SessionID = c.stats.SessionID
}
snap.AllowedWindowClasses = c.allowedClasses
snap.EnforcementLevel = c.enforcementLevel
snap.ReflectionStatus = c.reflectionStatus
snap.ReflectionRecap = c.reflectionRecap
snap.CarryForward = c.carryForward
return store.Save(c.snapshotPath, snap)
}
// EnterPlanning moves Locked -> Planning.
func (c *Mode) EnterPlanning() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
if err != nil {
return err
}
c.runtimeState = next
c.resetCoachLocked()
c.startTasksFetchLocked()
c.startKnowledgeFetchLocked()
return c.persistLocked()
}
// AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Mode) AllowedClassesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.allowedClasses...)
}
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
// only.
func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel {
c.mu.Lock()
defer c.mu.Unlock()
return c.enforcementLevel
}
// StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning ->
// Active.
func (c *Mode) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
c.mu.Lock()
defer c.mu.Unlock()
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
if err != nil {
return err
}
commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate)
if err != nil {
return err
}
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted)
if err != nil {
return err
}
now := c.clock()
c.runtimeState = next
c.commitment = &commitment
c.deadline = now.Add(timebox)
c.outcomePending = ""
c.resetCoachLocked()
c.allowedClasses = append([]string(nil), allowedClasses...)
c.enforcementLevel = level
c.resetDriftLocked()
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: now.Unix(),
Buckets: map[bucketKey]time.Duration{},
OnTask: map[bucketKey]time.Duration{},
OffTask: map[bucketKey]time.Duration{},
}
seed := c.latestWindow
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
c.applyEvent(now, seed)
return c.persistLocked()
}
// Complete moves Active -> Review with a "completed" outcome.
func (c *Mode) Complete() error { return c.enterReview("completed") }
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
func (c *Mode) Expire() error { return c.enterReview("expired") }
func (c *Mode) enterReview(outcome string) error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
if err != nil {
return err
}
if c.commitment != nil {
completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete)
if err != nil {
return err
}
c.commitment.State = completed
}
// 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
}
c.runtimeState = next
c.outcomePending = outcome
c.startReflectionFetchLocked()
return c.persistLocked()
}
// End moves Review -> Locked, writes the session summary to the audit chain,
// and clears the commitment and stats.
func (c *Mode) End() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
if err != nil {
return err
}
if c.stats != nil {
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
// State integrity over audit completeness: the transition still
// completes. Surfaced for the operator; no auto-retry in M1.
log.Printf("focus: audit append failed: %v", err)
}
}
c.runtimeState = next
c.commitment = nil
c.deadline = time.Time{}
c.stats = nil
c.outcomePending = ""
c.allowedClasses = nil
c.enforcementLevel = ""
c.resetDriftLocked()
return c.persistLocked()
}
func (c *Mode) buildSummaryLocked() store.SessionSummary {
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
for k, d := range c.stats.Buckets {
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds })
outcome := c.outcomePending
if outcome == "" {
outcome = "completed"
}
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
}
return store.SessionSummary{
SessionID: c.stats.SessionID,
NextAction: na,
SuccessCond: sc,
Outcome: outcome,
StartedUnix: c.stats.StartedUnix,
EndedUnix: c.clock().Unix(),
SwitchCount: c.stats.SwitchCount,
Buckets: buckets,
}
}