// 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, } }