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:
2026-06-05 18:00:49 -04:00
parent 258de2c14b
commit 7d69a1f320
57 changed files with 4069 additions and 627 deletions
+127
View File
@@ -0,0 +1,127 @@
// Package domain holds the core commitment types and validation. These are
// pure data types with no I/O.
package domain
import (
"errors"
"strings"
"time"
"github.com/google/uuid"
)
const PolicySchemaVersion = 1
type CommitmentSource string
const (
SourceManual CommitmentSource = "manual"
SourcePlanner CommitmentSource = "planner"
SourceRecurring CommitmentSource = "recurring"
SourceRecovery CommitmentSource = "recovery"
SourceTemplate CommitmentSource = "template"
)
type CommitmentState string
const (
CommitmentDraft CommitmentState = "draft"
CommitmentActive CommitmentState = "active"
CommitmentPaused CommitmentState = "paused"
CommitmentCompleted CommitmentState = "completed"
CommitmentAbandoned CommitmentState = "abandoned"
CommitmentViolated CommitmentState = "violated"
)
type RuntimeState string
const (
RuntimeLocked RuntimeState = "locked"
RuntimePlanning RuntimeState = "planning"
RuntimeActive RuntimeState = "active"
RuntimeTransition RuntimeState = "transition"
RuntimeReview RuntimeState = "review"
RuntimeAdminOverride RuntimeState = "admin_override"
)
type EnforcementLevel string
const (
EnforcementObserve EnforcementLevel = "observe"
EnforcementWarn EnforcementLevel = "warn"
EnforcementBlock EnforcementLevel = "block"
EnforcementLocked EnforcementLevel = "locked"
)
// AllowedContext is carried by PolicySnapshot. Matching logic arrives in a
// later milestone; M0 only stores it.
type AllowedContext struct {
WindowClasses []string `json:"window_classes"`
WindowTitleSubstrings []string `json:"window_title_substrings"`
Domains []string `json:"domains"`
Repos []string `json:"repos"`
Commands []string `json:"commands"`
}
type Commitment struct {
ID string `json:"id"`
CreatedAtUnixSecs int64 `json:"created_at_unix_secs"`
Source CommitmentSource `json:"source"`
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
State CommitmentState `json:"state"`
}
type PolicySnapshot struct {
ID string `json:"id"`
CommitmentID string `json:"commitment_id"`
SchemaVersion int `json:"schema_version"`
CreatedAtUnixSecs int64 `json:"created_at_unix_secs"`
RuntimeState RuntimeState `json:"runtime_state"`
EnforcementLevel EnforcementLevel `json:"enforcement_level"`
AllowedContext AllowedContext `json:"allowed_context"`
}
var (
ErrMissingNextAction = errors.New("next action is required")
ErrMissingSuccessCondition = errors.New("success condition is required")
ErrMissingTimebox = errors.New("timebox must be nonzero")
)
// NewManual builds a validated draft commitment from user input.
func NewManual(nextAction, successCondition string, timebox time.Duration) (Commitment, error) {
nextAction = strings.TrimSpace(nextAction)
successCondition = strings.TrimSpace(successCondition)
if nextAction == "" {
return Commitment{}, ErrMissingNextAction
}
if successCondition == "" {
return Commitment{}, ErrMissingSuccessCondition
}
if timebox < time.Second {
return Commitment{}, ErrMissingTimebox
}
return Commitment{
ID: "commitment-" + uuid.Must(uuid.NewV7()).String(),
CreatedAtUnixSecs: time.Now().Unix(),
Source: SourceManual,
NextAction: nextAction,
SuccessCondition: successCondition,
TimeboxSecs: int64(timebox.Seconds()),
State: CommitmentDraft,
}, nil
}
// ForRuntime builds a minimal accepted policy snapshot for a commitment.
func ForRuntime(commitmentID string, runtime RuntimeState, level EnforcementLevel) PolicySnapshot {
return PolicySnapshot{
ID: "policy-" + uuid.Must(uuid.NewV7()).String(),
CommitmentID: commitmentID,
SchemaVersion: PolicySchemaVersion,
CreatedAtUnixSecs: time.Now().Unix(),
RuntimeState: runtime,
EnforcementLevel: level,
AllowedContext: AllowedContext{},
}
}
+95
View File
@@ -0,0 +1,95 @@
package domain
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestNewManualRejectsEmptyNextAction(t *testing.T) {
_, err := NewManual("", "tests pass", 25*time.Minute)
if err != ErrMissingNextAction {
t.Fatalf("want ErrMissingNextAction, got %v", err)
}
}
func TestNewManualRejectsEmptySuccessCondition(t *testing.T) {
_, err := NewManual("Refactor state", " ", 25*time.Minute)
if err != ErrMissingSuccessCondition {
t.Fatalf("want ErrMissingSuccessCondition, got %v", err)
}
}
func TestNewManualRejectsZeroTimebox(t *testing.T) {
_, err := NewManual("Refactor state", "tests pass", 0)
if err != ErrMissingTimebox {
t.Fatalf("want ErrMissingTimebox, got %v", err)
}
}
func TestNewManualPopulatesDraftCommitment(t *testing.T) {
c, err := NewManual(" Port domain ", "domain tests pass", 25*time.Minute)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.NextAction != "Port domain" {
t.Errorf("next action not trimmed: %q", c.NextAction)
}
if c.SuccessCondition != "domain tests pass" {
t.Errorf("success condition wrong: %q", c.SuccessCondition)
}
if c.TimeboxSecs != 1500 {
t.Errorf("timebox secs wrong: %d", c.TimeboxSecs)
}
if c.State != CommitmentDraft {
t.Errorf("state should be draft, got %s", c.State)
}
if c.Source != SourceManual {
t.Errorf("source should be manual, got %s", c.Source)
}
if !strings.HasPrefix(c.ID, "commitment-") {
t.Errorf("id should be prefixed, got %s", c.ID)
}
}
func TestNewManualRejectsSubSecondTimebox(t *testing.T) {
_, err := NewManual("Refactor state", "tests pass", 500*time.Millisecond)
if err != ErrMissingTimebox {
t.Fatalf("want ErrMissingTimebox, got %v", err)
}
}
func TestForRuntimePopulatesPolicySnapshot(t *testing.T) {
p := ForRuntime("commitment-123", RuntimeActive, EnforcementObserve)
if !strings.HasPrefix(p.ID, "policy-") {
t.Errorf("id should be prefixed, got %s", p.ID)
}
if p.CommitmentID != "commitment-123" {
t.Errorf("commitment id wrong: %s", p.CommitmentID)
}
if p.SchemaVersion != PolicySchemaVersion {
t.Errorf("schema version wrong: %d", p.SchemaVersion)
}
if p.RuntimeState != RuntimeActive {
t.Errorf("runtime state wrong: %s", p.RuntimeState)
}
if p.EnforcementLevel != EnforcementObserve {
t.Errorf("enforcement level wrong: %s", p.EnforcementLevel)
}
}
func TestCommitmentSerializesSnakeCaseEnums(t *testing.T) {
c, _ := NewManual("Port domain", "domain tests pass", 25*time.Minute)
data, err := json.Marshal(c)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
s := string(data)
if !strings.Contains(s, `"source":"manual"`) {
t.Errorf("missing snake_case source in %s", s)
}
if !strings.Contains(s, `"state":"draft"`) {
t.Errorf("missing snake_case state in %s", s)
}
}
+339
View File
@@ -0,0 +1,339 @@
package focus
import (
"context"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"log"
"strings"
"time"
)
const (
driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second
enforceTimeout = 5 * time.Second
nudgeDebounce = 5 * time.Minute
nudgeTimeout = 30 * time.Second
)
const recentTitlesMax = 10
const (
driftIdle = "idle"
driftPending = "pending"
driftOnTask = "ontask"
driftDrifting = "drifting"
)
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
// working; unmatched windows simply stay idle.
func (c *Mode) SetDriftJudge(j ai.DriftJudge) {
c.mu.Lock()
c.judge = j
c.mu.Unlock()
}
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
// enforcement; everything else behaves identically.
func (c *Mode) SetGuard(g enforce.Guard) {
c.mu.Lock()
defer c.mu.Unlock()
c.guard = g
}
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected.
func (c *Mode) SetNudge(n ai.Nudger) {
c.mu.Lock()
c.nudge = n
c.mu.Unlock()
}
// commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu.
func (c *Mode) commitmentLineLocked() string {
if c.commitment == nil {
return ""
}
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Mode) recentTitlesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.recentTitles...)
}
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Mode) resetDriftLocked() {
c.driftStatus = driftIdle
c.driftReason = ""
c.driftGen++
c.nudgeEpoch++
c.lastJudgedAt = time.Time{}
c.judgedWindows = map[string]ai.Verdict{}
c.recentTitles = nil
c.nudgeMessage = ""
c.lastNudgedAt = time.Time{}
}
// OnTask appends the current window class to the session allowed-context, clears
// drift, drops the cached verdict for the current window, and persists. The
// class now matches locally and is never re-judged.
func (c *Mode) OnTask() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
var class string
if c.stats != nil {
class = c.stats.Current.Class
}
if strings.TrimSpace(class) != "" {
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
}
delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title))
}
c.driftStatus = driftOnTask
c.driftReason = ""
return c.persistLocked()
}
// Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current window so it may be judged again later.
func (c *Mode) Refocus() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
if c.stats != nil {
delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title))
}
c.driftStatus = driftIdle
c.driftReason = ""
return c.persistLocked()
}
// RecordWindow ingests one sensor observation. It accumulates time only while
// Active, always tracks the latest window for display, and fires onChange after
// releasing the mutex.
func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) {
c.mu.Lock()
c.latestWindow = snap
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
c.mu.Unlock()
c.notify()
return
}
now := c.clock()
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
c.applyEvent(now, snap)
c.recordTitleLocked(snap.Title)
launch := c.evaluateDriftLocked(now, snap)
enforceAct := c.enforceActionLocked()
c.mu.Unlock()
if launch != nil {
go launch()
}
if enforceAct != nil {
go enforceAct()
}
c.notify()
}
// enforceActionLocked returns the minimize thunk when this observation should be
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
// returned func performs blocking X11 I/O and MUST run after the caller releases
// c.mu, following the off-lock async-I/O discipline used by the other roles.
func (c *Mode) enforceActionLocked() func() {
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
return nil
}
guard := c.guard
return func() {
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
defer cancel()
if err := guard.MinimizeActive(ctx); err != nil {
log.Printf("focus: enforce minimize failed: %v", err)
}
}
}
// evaluateDriftLocked runs the local-first drift pipeline for one observation
// and updates synchronous drift state. When an async judgment is warranted it
// returns the judging closure; the caller runs it in a goroutine after
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
// already verified the runtime is Active.
func (c *Mode) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
// where the semantic nudge runs — the drift judge stays silent here, so the
// two are mutually exclusive per observation.
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if evidence.MatchesAllowed(ac, class, title) {
c.driftStatus = driftOnTask
c.driftReason = ""
return c.maybeNudgeLocked(now)
}
// Past this point the window is not a local on-task match: the on-task
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
// epoch so an in-flight nudge result is discarded rather than surfacing stale
// when the user returns.
c.nudgeMessage = ""
c.nudgeEpoch++
// 2. Per-window cache, keyed by class+title. A verdict for one window of an
// app must not leak to a sibling window of the same class — a browser hosts
// both an on-task reading page and an off-task chat under one window class, so
// keying by class alone latched one tab's verdict onto every other tab.
key := verdictKey(class, title)
if v, ok := c.judgedWindows[key]; ok {
c.applyVerdictLocked(v)
return nil
}
// 3. No judge wired: never block; leave idle.
if c.judge == nil {
c.driftStatus = driftIdle
c.driftReason = ""
return nil
}
// 4. Debounce: at most one judgment per driftDebounce window.
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
return nil
}
prevStatus, prevReason := c.driftStatus, c.driftReason
c.driftGen++
gen := c.driftGen
c.lastJudgedAt = now
c.driftStatus = driftPending
c.driftReason = ""
judge := c.judge
commitment := c.commitmentLineLocked()
return func() {
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
defer cancel()
v, err := judge.JudgeDrift(ctx, commitment, class, title)
c.mu.Lock()
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: a newer judgment started or the session ended
}
if err != nil {
// Degrade: never fabricate drift. Revert the optimistic pending.
if c.driftStatus == driftPending {
c.driftStatus = prevStatus
c.driftReason = prevReason
}
c.mu.Unlock()
log.Printf("focus: drift judge failed: %v", err)
c.notify()
return
}
c.judgedWindows[key] = v
var enforceAct func()
if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key {
c.applyVerdictLocked(v)
enforceAct = c.enforceActionLocked()
}
c.mu.Unlock()
if enforceAct != nil {
enforceAct()
}
c.notify()
}
}
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
// local-match path. It returns the nudging closure when eligible (judge wired,
// at least two titles of history, debounce elapsed), else nil. The closure
// follows the drift-judge discipline: it runs in a goroutine after the caller
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
// episode or a session change — is discarded rather than surfacing stale, never
// fabricates a concern on error, and notifies only after unlocking. Caller holds
// mu and has already verified the runtime is Active.
func (c *Mode) maybeNudgeLocked(now time.Time) func() {
if c.nudge == nil || len(c.recentTitles) < 2 {
return nil
}
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
return nil
}
c.lastNudgedAt = now
epoch := c.nudgeEpoch
nudge := c.nudge
commitment := c.commitmentLineLocked()
titles := append([]string(nil), c.recentTitles...)
return func() {
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
defer cancel()
msg, err := nudge.Nudge(ctx, commitment, titles)
c.mu.Lock()
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: the on-task stretch ended (drift episode) or the session changed
}
if err != nil {
c.mu.Unlock()
log.Printf("focus: nudge failed: %v", err)
return // never fabricate a concern; leave any prior message intact
}
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
c.mu.Unlock()
c.notify()
}
}
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Mode) recordTitleLocked(title string) {
t := strings.TrimSpace(title)
if t == "" {
return
}
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
return
}
c.recentTitles = append(c.recentTitles, t)
if len(c.recentTitles) > recentTitlesMax {
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
}
}
// verdictKey identifies the judged window for the per-window verdict cache. It
// combines the window class with the scrubbed, casefolded title so sibling
// windows of one app (a browser's reading tab vs its chat tab) are judged
// independently, while transient title noise (clocks, counts) does not split a
// window into ever-changing keys that defeat the cache.
func verdictKey(class, title string) string {
return strings.ToLower(strings.TrimSpace(class)) + "\x00" + strings.ToLower(evidence.ScrubTitle(title))
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Mode) applyVerdictLocked(v ai.Verdict) {
if v.OnTask {
c.driftStatus = driftOnTask
c.driftReason = ""
return
}
c.driftStatus = driftDrifting
c.driftReason = v.Reason
}
+50
View File
@@ -0,0 +1,50 @@
package focus
import (
"path/filepath"
"keel/internal/harness"
"keel/internal/mode"
)
// build constructs a focus Mode under the mode's namespaced dir and injects the
// shared services through the existing setters (the same API tests use).
func build(svc harness.Services) (*Mode, error) {
m, err := New(filepath.Join(svc.Dir, "state.json"))
if err != nil {
return nil, err
}
if svc.Clock != nil {
m.SetClock(svc.Clock)
}
m.SetCoach(svc.AI)
m.SetReviewer(svc.AI)
m.SetDriftJudge(svc.AI)
m.SetNudge(svc.AI)
m.SetTasks(svc.Tasks)
m.SetKnowledge(svc.Knowledge)
m.SetGuard(svc.Enforce)
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m, nil
}
// Factory builds a fresh focus mode entering Planning (used by harness.Start).
func Factory(svc harness.Services) mode.Mode {
m, err := build(svc)
if err != nil {
m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works
m.SetOnChange(svc.Notify)
}
_ = m.EnterPlanning()
return m
}
// Restore loads a persisted focus session and reports whether one was live
// (Planning/Active/Transition/Review). Used at startup to re-adopt a session.
func Restore(svc harness.Services) (*Mode, bool, error) {
m, err := build(svc)
if err != nil {
return nil, false, err
}
return m, m.Active(), nil
}
+358
View File
@@ -0,0 +1,358 @@
// 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,
}
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
package focus
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"keel/internal/evidence"
"keel/internal/mode"
"keel/internal/mode/focus/domain"
)
// Compile-time assertions: focus.Mode must satisfy the harness contracts.
var _ mode.Mode = (*Mode)(nil)
var _ mode.EvidenceConsumer = (*Mode)(nil)
var _ mode.Expirer = (*Mode)(nil)
// Kind identifies this mode to the harness.
func (c *Mode) Kind() string { return "focus" }
var errBadPayload = errors.New("focus: invalid command payload")
type commitmentPayload struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
Enforce bool `json:"enforce"`
}
// Command maps surface command names onto the existing focus transitions.
func (c *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
switch name {
case "planning":
return c.EnterPlanning()
case "coach":
var r struct {
Intent string `json:"intent"`
}
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
return c.RequestCoach(r.Intent)
case "commitment":
var r commitmentPayload
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
level := domain.EnforcementWarn
if r.Enforce {
level = domain.EnforcementBlock
}
return c.StartManualCommitment(r.NextAction, r.SuccessCondition,
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
case "complete":
return c.Complete()
case "end":
return c.End()
case "refocus":
return c.Refocus()
case "ontask":
return c.OnTask()
default:
return fmt.Errorf("focus: unknown command %q", name)
}
}
// View returns the focus state projection (the existing State shape).
func (c *Mode) View() any { return c.State() }
// Active is true from Planning through Review. End() drives the runtime back to
// Locked, which the harness reads as "done" and releases.
func (c *Mode) Active() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.runtimeState != domain.RuntimeLocked
}
// OnWindow forwards the sensor stream to the existing evidence recorder.
func (c *Mode) OnWindow(w evidence.WindowSnapshot) { c.RecordWindow(w) }
+378
View File
@@ -0,0 +1,378 @@
package focus
import (
"context"
"errors"
"fmt"
"keel/internal/ai"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"keel/internal/tasks"
"strings"
"time"
)
const coachTimeout = 60 * time.Second
const (
coachIdle = "idle"
coachPending = "pending"
coachReady = "ready"
coachError = "error"
)
const tasksTimeout = 30 * time.Second
const (
tasksIdle = "idle"
tasksPending = "pending"
tasksReady = "ready"
tasksError = "error"
)
const knowledgeTimeout = 10 * time.Second
const (
knowledgeIdle = "idle"
knowledgePending = "pending"
knowledgeReady = "ready"
knowledgeAbsent = "absent"
knowledgeError = "error"
)
const reflectionTimeout = 30 * time.Second
const reflectionHistoryN = 5
// reflectionTopBuckets caps how many per-window time buckets the finished-session
// block lists for the reviewer, keeping the prompt compact.
const reflectionTopBuckets = 3
const (
reflectionIdle = "idle"
reflectionPending = "pending"
reflectionReady = "ready"
reflectionAbsent = "absent"
)
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Mode) SetCoach(coach ai.Coach) {
c.mu.Lock()
c.coach = coach
c.mu.Unlock()
}
// resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu.
func (c *Mode) resetCoachLocked() {
c.coachStatus = coachIdle
c.coachProposal = nil
c.coachErr = ""
c.coachGen++
}
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent.
func (c *Mode) SetTasks(p tasks.Provider) {
c.mu.Lock()
c.tasksProvider = p
c.mu.Unlock()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu.
func (c *Mode) startTasksFetchLocked() {
c.tasksList = nil
if c.tasksProvider == nil {
c.tasksStatus = tasksIdle
return
}
c.tasksGen++
gen := c.tasksGen
c.tasksStatus = tasksPending
provider := c.tasksProvider
var list []tasks.Task
var err error
c.async.Run(tasksTimeout,
func(ctx context.Context) { list, err = provider.Today(ctx) },
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.tasksStatus = tasksError
c.tasksList = nil
} else {
c.tasksStatus = tasksReady
c.tasksList = list
}
})
}
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded.
func (c *Mode) SetKnowledge(s knowledge.Source) {
c.mu.Lock()
c.knowledgeSrc = s
c.mu.Unlock()
}
// SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default.
func (c *Mode) SetKnowledgePath(path string) {
c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning
if planning {
c.startKnowledgeFetchLocked()
}
c.mu.Unlock()
if planning {
c.notify()
}
}
// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set.
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu.
func (c *Mode) startKnowledgeFetchLocked() {
c.knowledgeText = ""
c.knowledgeChars = 0
if c.knowledgeSrc == nil {
c.knowledgeStatus = knowledgeIdle
return
}
c.knowledgeGen++
gen := c.knowledgeGen
c.knowledgeStatus = knowledgePending
src := c.knowledgeSrc
path := c.knowledgePath
var prof knowledge.Profile
var err error
c.async.Run(knowledgeTimeout,
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.knowledgeStatus = knowledgeError
c.knowledgeText = ""
c.knowledgeChars = 0
if prof.Path != "" {
c.knowledgePath = prof.Path
}
} else if prof.Text == "" {
c.knowledgeStatus = knowledgeAbsent
c.knowledgeText = ""
c.knowledgeChars = 0
c.knowledgePath = prof.Path
} else {
c.knowledgeStatus = knowledgeReady
c.knowledgeText = prof.Text
c.knowledgeChars = len(prof.Text)
c.knowledgePath = prof.Path
}
})
}
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward.
func (c *Mode) SetReviewer(r ai.Reviewer) {
c.mu.Lock()
c.reviewer = r
c.mu.Unlock()
}
// startReflectionFetchLocked kicks off an asynchronous reflection when a
// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the
// completion guard is generation-only (not state-gated): the carry-forward must
// still apply if the user clicks End before the reviewer returns. A superseded
// review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu.
func (c *Mode) startReflectionFetchLocked() {
c.reflectionRecap = ""
c.carryForward = ""
if c.reviewer == nil {
c.reflectionStatus = reflectionIdle
return
}
c.reflectionGen++
gen := c.reflectionGen
c.reflectionStatus = reflectionPending
reviewer := c.reviewer
finished := c.buildReflectionFinishedLocked()
// Read the history synchronously, here under the lock, on purpose: it must
// happen-before End appends the just-finished session to the audit chain, so
// that session is excluded from "recent history" and not double-counted (it
// is already carried in `finished`). Moving this into the goroutine would
// race with End's append and reintroduce that double-count. The read is
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
// on any hot path.
history := buildReflectionHistory(c.auditPath)
var refl ai.Reflection
var err error
c.async.Run(reflectionTimeout,
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
func() bool { return gen != c.reflectionGen },
func() {
if err != nil || strings.TrimSpace(refl.Recap) == "" {
c.reflectionStatus = reflectionAbsent
c.reflectionRecap = ""
c.carryForward = ""
} else {
c.reflectionStatus = reflectionReady
c.reflectionRecap = refl.Recap
c.carryForward = refl.CarryForward
}
_ = c.persistLocked()
})
}
// 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 *Mode) 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
}
// buildReflectionHistory renders the last few prior sessions as compact lines.
// The just-finished session is not yet in the chain (End appends it), so it is
// not double-counted. Returns "" when there is no usable history.
func buildReflectionHistory(auditPath string) string {
sums, err := store.RecentSessions(auditPath, reflectionHistoryN)
if err != nil || len(sums) == 0 {
return ""
}
var b strings.Builder
for _, s := range sums {
top := ""
if len(s.Buckets) > 0 {
top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60)
}
fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top)
}
return strings.TrimRight(b.String(), "\n")
}
// composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu.
func (c *Mode) composedGroundingLocked() string {
g := c.knowledgeText
if c.carryForward != "" {
if g != "" {
g += "\n\n"
}
g += "Last session's takeaway: " + c.carryForward
}
return g
}
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted.
func (c *Mode) RequestCoach(intent string) error {
c.mu.Lock()
if c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return ErrNotPlanning
}
if c.coach == nil {
c.coachStatus = coachError
c.coachErr = "coach unavailable"
c.coachProposal = nil
c.mu.Unlock()
c.notify()
return nil
}
c.coachGen++
gen := c.coachGen
c.coachStatus = coachPending
c.coachErr = ""
c.coachProposal = nil
coach := c.coach
grounding := c.composedGroundingLocked()
c.mu.Unlock()
c.notify()
var prop ai.Proposal
var err error
c.async.Run(coachTimeout,
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.coachStatus = coachError
c.coachErr = coachErrorMessage(err)
c.coachProposal = nil
} else {
c.coachStatus = coachReady
c.coachProposal = &prop
c.coachErr = ""
}
})
return nil
}
func coachErrorMessage(err error) string {
switch {
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
return "coach returned an unusable response"
case errors.Is(err, context.DeadlineExceeded):
return "coach timed out"
default:
return "coach unavailable"
}
}
@@ -0,0 +1,115 @@
// Package statemachine holds the pure runtime and commitment transition
// functions. No I/O, no shared state.
package statemachine
import (
"fmt"
"keel/internal/mode/focus/domain"
)
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
// and admin-override exit targets are flattened into distinct actions so the
// action type stays a simple value.
type RuntimeAction string
const (
EnterPlanning RuntimeAction = "enter_planning"
CancelOrTimeout RuntimeAction = "cancel_or_timeout"
ActivateAccepted RuntimeAction = "activate_accepted"
ActivateRejected RuntimeAction = "activate_rejected"
StartTransition RuntimeAction = "start_transition"
CompleteForReview RuntimeAction = "complete_for_review"
SevereViolation RuntimeAction = "severe_violation"
ReturnToActive RuntimeAction = "return_to_active"
SwitchTask RuntimeAction = "switch_task"
EndTransitionForReview RuntimeAction = "end_transition_for_review"
ContinuePlanning RuntimeAction = "continue_planning"
EndWorkPeriod RuntimeAction = "end_work_period"
EnterAdminOverride RuntimeAction = "enter_admin_override"
ExitAdminOverrideToLocked RuntimeAction = "exit_admin_override_to_locked"
ExitAdminOverrideToPlanning RuntimeAction = "exit_admin_override_to_planning"
)
type CommitmentAction string
const (
CommitmentActivate CommitmentAction = "activate"
PauseForTransition CommitmentAction = "pause_for_transition"
ReturnFromPause CommitmentAction = "return_from_pause"
Complete CommitmentAction = "complete"
Abandon CommitmentAction = "abandon"
Violate CommitmentAction = "violate"
RecoverExplicitly CommitmentAction = "recover_explicitly"
)
// IllegalTransitionError reports a rejected transition.
type IllegalTransitionError struct {
From string
Action string
}
func (e IllegalTransitionError) Error() string {
return fmt.Sprintf("illegal transition from %s via %s", e.From, e.Action)
}
// PolicyRejectedError reports an activation attempted with an unaccepted policy.
type PolicyRejectedError struct{}
func (PolicyRejectedError) Error() string { return "policy was rejected" }
func TransitionRuntime(current domain.RuntimeState, action RuntimeAction) (domain.RuntimeState, error) {
type key struct {
s domain.RuntimeState
a RuntimeAction
}
table := map[key]domain.RuntimeState{
{domain.RuntimeLocked, EnterPlanning}: domain.RuntimePlanning,
{domain.RuntimePlanning, ActivateAccepted}: domain.RuntimeActive,
{domain.RuntimePlanning, CancelOrTimeout}: domain.RuntimeLocked,
{domain.RuntimeActive, StartTransition}: domain.RuntimeTransition,
{domain.RuntimeActive, CompleteForReview}: domain.RuntimeReview,
{domain.RuntimeActive, SevereViolation}: domain.RuntimeLocked,
{domain.RuntimeTransition, ReturnToActive}: domain.RuntimeActive,
{domain.RuntimeTransition, SwitchTask}: domain.RuntimePlanning,
{domain.RuntimeTransition, EndTransitionForReview}: domain.RuntimeReview,
{domain.RuntimeTransition, CancelOrTimeout}: domain.RuntimeLocked,
{domain.RuntimeReview, ContinuePlanning}: domain.RuntimePlanning,
{domain.RuntimeReview, EndWorkPeriod}: domain.RuntimeLocked,
{domain.RuntimeAdminOverride, ExitAdminOverrideToLocked}: domain.RuntimeLocked,
{domain.RuntimeAdminOverride, ExitAdminOverrideToPlanning}: domain.RuntimePlanning,
}
if action == ActivateRejected && current == domain.RuntimePlanning {
return current, PolicyRejectedError{}
}
if action == EnterAdminOverride {
return domain.RuntimeAdminOverride, nil
}
if next, ok := table[key{current, action}]; ok {
return next, nil
}
return current, IllegalTransitionError{From: string(current), Action: string(action)}
}
func TransitionCommitment(current domain.CommitmentState, action CommitmentAction) (domain.CommitmentState, error) {
type key struct {
s domain.CommitmentState
a CommitmentAction
}
table := map[key]domain.CommitmentState{
{domain.CommitmentDraft, CommitmentActivate}: domain.CommitmentActive,
{domain.CommitmentActive, PauseForTransition}: domain.CommitmentPaused,
{domain.CommitmentPaused, ReturnFromPause}: domain.CommitmentActive,
{domain.CommitmentActive, Complete}: domain.CommitmentCompleted,
{domain.CommitmentActive, Abandon}: domain.CommitmentAbandoned,
{domain.CommitmentActive, Violate}: domain.CommitmentViolated,
{domain.CommitmentPaused, Abandon}: domain.CommitmentAbandoned,
{domain.CommitmentViolated, RecoverExplicitly}: domain.CommitmentActive,
{domain.CommitmentViolated, Abandon}: domain.CommitmentAbandoned,
}
if next, ok := table[key{current, action}]; ok {
return next, nil
}
return current, IllegalTransitionError{From: string(current), Action: string(action)}
}
@@ -0,0 +1,54 @@
package statemachine
import (
"testing"
"keel/internal/mode/focus/domain"
)
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
got, err := TransitionRuntime(domain.RuntimePlanning, ActivateAccepted)
if err != nil || got != domain.RuntimeActive {
t.Fatalf("accepted: got %s err %v", got, err)
}
_, err = TransitionRuntime(domain.RuntimePlanning, ActivateRejected)
if err == nil {
t.Fatalf("rejected policy should error")
}
}
func TestActiveCanCompleteForReview(t *testing.T) {
got, err := TransitionRuntime(domain.RuntimeActive, CompleteForReview)
if err != nil || got != domain.RuntimeReview {
t.Fatalf("got %s err %v", got, err)
}
}
func TestReviewEndsToLocked(t *testing.T) {
got, err := TransitionRuntime(domain.RuntimeReview, EndWorkPeriod)
if err != nil || got != domain.RuntimeLocked {
t.Fatalf("got %s err %v", got, err)
}
}
func TestLockedCannotBecomeActiveDirectly(t *testing.T) {
_, err := TransitionRuntime(domain.RuntimeLocked, ActivateAccepted)
if err == nil {
t.Fatalf("locked->active must be illegal")
}
}
func TestCommitmentDraftActivatesAndViolatedRecovers(t *testing.T) {
got, err := TransitionCommitment(domain.CommitmentDraft, CommitmentActivate)
if err != nil || got != domain.CommitmentActive {
t.Fatalf("draft->active: got %s err %v", got, err)
}
got, err = TransitionCommitment(domain.CommitmentViolated, RecoverExplicitly)
if err != nil || got != domain.CommitmentActive {
t.Fatalf("violated->active: got %s err %v", got, err)
}
_, err = TransitionCommitment(domain.CommitmentViolated, ReturnFromPause)
if err == nil {
t.Fatalf("violated->returnFromPause must be illegal")
}
}
+113
View File
@@ -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},
}
}
+197
View File
@@ -0,0 +1,197 @@
package focus
import (
"keel/internal/mode/focus/domain"
"sort"
"time"
)
// CommitmentView is the UI projection of the active commitment.
type CommitmentView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
}
// ProposalView / CoachView project the ephemeral planning-coach state.
type ProposalView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
}
// DriftView is the active-only drift projection. Nudge is a separate axis from
// Status: it is populated precisely when Status is "ontask" (semantic drift
// inside an allowed app), so it cannot be folded into the status enum.
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Nudge string `json:"nudge,omitempty"`
Enforced bool `json:"enforced,omitempty"`
}
type CoachView struct {
Status string `json:"status"`
Proposal *ProposalView `json:"proposal,omitempty"`
Error string `json:"error,omitempty"`
}
// TaskView is one to-do item in the planning tasks list.
type TaskView struct {
ID string `json:"id"`
Title string `json:"title"`
Day string `json:"day,omitempty"`
}
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
type TasksView struct {
Status string `json:"status"`
Tasks []TaskView `json:"tasks,omitempty"`
}
// KnowledgeView projects the ephemeral planning knowledge state (the standing
// profile that grounds the coach). The profile text is intentionally omitted —
// only its presence, source path, and size cross the wire.
type KnowledgeView struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Chars int `json:"chars,omitempty"`
}
// ReflectionView projects the reviewer's output. Recap is shown on Review;
// CarryForward is shown on the next Planning screen. Unlike the knowledge
// profile, these short lines exist to be displayed, so they cross the wire.
type ReflectionView struct {
Status string `json:"status,omitempty"`
Recap string `json:"recap,omitempty"`
CarryForward string `json:"carry_forward,omitempty"`
}
// WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct {
Class string `json:"class"`
Title string `json:"title"`
}
type BucketView struct {
Class string `json:"class"`
Title string `json:"title"`
Seconds int64 `json:"seconds"`
}
type EvidenceView struct {
Available bool `json:"available"`
Reason string `json:"reason"`
Current WindowView `json:"current"`
SwitchCount int `json:"switch_count"`
Buckets []BucketView `json:"buckets"`
}
// State is the broadcastable view of the controller.
type State struct {
RuntimeState domain.RuntimeState `json:"runtime_state"`
Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Reflection *ReflectionView `json:"reflection,omitempty"`
Drift *DriftView `json:"drift,omitempty"`
}
func (c *Mode) stateLocked() State {
st := State{RuntimeState: c.runtimeState}
if c.commitment != nil {
view := &CommitmentView{
NextAction: c.commitment.NextAction,
SuccessCondition: c.commitment.SuccessCondition,
TimeboxSecs: c.commitment.TimeboxSecs,
}
if !c.deadline.IsZero() {
view.DeadlineUnixSecs = c.deadline.Unix()
}
st.Commitment = view
}
if c.stats != nil {
st.Evidence = &EvidenceView{
Available: c.stats.Current.Health.Available,
Reason: c.stats.Current.Health.Reason,
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
SwitchCount: c.stats.SwitchCount,
Buckets: bucketViews(c.stats.Buckets),
}
}
if c.runtimeState == domain.RuntimePlanning {
// Surface the live window so planning can show the real class to copy into
// the allowed-apps field — exact class names are otherwise invisible, and a
// non-matching token silently disables the local on-task fast path.
st.Evidence = &EvidenceView{
Available: c.latestWindow.Health.Available,
Reason: c.latestWindow.Health.Reason,
Current: WindowView{Class: c.latestWindow.Class, Title: c.latestWindow.Title},
}
status := c.coachStatus
if status == "" {
status = coachIdle
}
cv := &CoachView{Status: status, Error: c.coachErr}
if c.coachProposal != nil {
cv.Proposal = &ProposalView{
NextAction: c.coachProposal.NextAction,
SuccessCondition: c.coachProposal.SuccessCondition,
TimeboxSecs: c.coachProposal.TimeboxSecs,
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
}
}
st.Coach = cv
if c.tasksProvider != nil {
tstatus := c.tasksStatus
if tstatus == "" {
tstatus = tasksIdle
}
tv := &TasksView{Status: tstatus}
for _, t := range c.tasksList {
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
}
st.Tasks = tv
}
if c.knowledgeSrc != nil {
kstatus := c.knowledgeStatus
if kstatus == "" {
kstatus = knowledgeIdle
}
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
}
if c.carryForward != "" {
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
}
}
if c.runtimeState == domain.RuntimeReview {
rstatus := c.reflectionStatus
if rstatus == "" {
rstatus = reflectionIdle
}
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
}
if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus
if status == "" {
status = driftIdle
}
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
}
return st
}
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
out := make([]BucketView, 0, len(buckets))
for k, d := range buckets {
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
return out
}
+44
View File
@@ -0,0 +1,44 @@
// Package mode defines the contract the harness uses to host a single
// collect→brain→act unit. Focus and off-screen are implementations.
package mode
import (
"context"
"encoding/json"
"time"
"keel/internal/evidence"
)
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
// Kind identifies the mode ("focus", "offscreen").
Kind() string
// Command routes a surface-issued command into the mode. It returns an
// error for illegal/invalid commands; the web layer maps it to HTTP.
Command(ctx context.Context, name string, payload json.RawMessage) error
// View projects the mode's current state for surfaces (JSON-marshalable).
View() any
// Active reports whether the mode still holds the harness. When it returns
// false the harness releases the mode and goes idle.
Active() bool
}
// EvidenceConsumer is implemented by modes that read the window sensor stream.
type EvidenceConsumer interface {
OnWindow(evidence.WindowSnapshot)
}
// Expirer is implemented by modes with a server-authoritative deadline. Expire
// takes no context to match focus's existing Expire() error method, so focus
// satisfies this interface with zero changes to its tested surface.
type Expirer interface {
Deadline() time.Time
Expire() error
}
// Envelope is the surfacing wrapper: the active mode's kind plus its View().
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"`
}
+131
View File
@@ -0,0 +1,131 @@
package offscreen
import (
"context"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
// goalsPath is the standing-goals note loaded for the off-screen brief.
const goalsPath = "~/owc/goals-2026.md"
// maxBriefBytes caps the assembled brief so the propose prompt stays bounded.
const maxBriefBytes = 8 * 1024
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
const bugGlob = "bug-*.md"
// assembleBrief builds the off-screen prompt context from today's tasks, the
// standing goals note, and the life-domain notes. It is best-effort: it never
// panics and never returns an error, and it tolerates nil ports and missing
// files (degrading to a short, mostly-empty brief).
func (m *Mode) assembleBrief() string {
var b strings.Builder
b.WriteString("## Today's tasks\n")
b.WriteString(m.briefTasks())
b.WriteString("\n")
if goals := m.briefGoals(); goals != "" {
b.WriteString("## Goals\n")
b.WriteString(goals)
b.WriteString("\n\n")
}
if domains := m.briefLifeDomains(); domains != "" {
b.WriteString("## Life domains\n")
b.WriteString(domains)
b.WriteString("\n")
}
return truncateBrief(b.String(), maxBriefBytes)
}
// briefTasks lists today's task titles, or "(none)" when the port is absent,
// errors, or returns nothing.
func (m *Mode) briefTasks() string {
if m.tasks == nil {
return "(none)\n"
}
list, err := m.tasks.Today(context.Background())
if err != nil || len(list) == 0 {
return "(none)\n"
}
var b strings.Builder
for _, t := range list {
title := strings.TrimSpace(t.Title)
if title == "" {
continue
}
b.WriteString("- ")
b.WriteString(title)
b.WriteString("\n")
}
if b.Len() == 0 {
return "(none)\n"
}
return b.String()
}
// briefGoals returns the goals note text, or "" when the port is absent or the
// file is missing/empty.
func (m *Mode) briefGoals() string {
if m.know == nil {
return ""
}
p, err := m.know.Load(context.Background(), goalsPath)
if err != nil {
return ""
}
return strings.TrimSpace(p.Text)
}
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
// port (so reads honor its truncation) and concatenates their text under one
// section. Returns "" when the port is absent or no notes are readable. File
// access stays behind the port; when the port is nil it is skipped entirely.
func (m *Mode) briefLifeDomains() string {
if m.know == nil {
return ""
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
if err != nil || len(matches) == 0 {
return ""
}
var b strings.Builder
for _, path := range matches {
p, err := m.know.Load(context.Background(), path)
if err != nil {
continue
}
text := strings.TrimSpace(p.Text)
if text == "" {
continue
}
b.WriteString("### ")
b.WriteString(filepath.Base(path))
b.WriteString("\n")
b.WriteString(text)
b.WriteString("\n\n")
}
return strings.TrimRight(b.String(), "\n")
}
// truncateBrief clips s to at most max bytes with a marker, keeping the prompt
// bounded. It backs up to a rune boundary so it never splits a multibyte rune.
func truncateBrief(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)"
}
+158
View File
@@ -0,0 +1,158 @@
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
// off-screen action from the life-domain frame and files it on confirm. It is
// one-shot — after confirm or dismiss it goes inactive and the harness releases
// it.
package offscreen
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/tasks"
)
const proposeTimeout = 60 * time.Second
const (
statusPending = "pending"
statusProposed = "proposed"
statusError = "error"
statusDone = "done"
)
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
type Mode struct {
mu sync.Mutex
async harness.Async
ai *ai.Service
tasks tasks.Provider
know knowledge.Source // may be nil; assembleBrief must guard
status string
gen int
proposal *ai.OffscreenProposal
errMsg string
done bool
}
var _ mode.Mode = (*Mode)(nil)
// New builds an off-screen mode from the shared services. It does not start the
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
func New(svc harness.Services) *Mode {
m := &Mode{
ai: svc.AI,
tasks: svc.Tasks,
know: svc.Knowledge,
status: statusPending,
}
m.async = harness.NewAsync(&m.mu, svc.Notify)
return m
}
func (m *Mode) Kind() string { return "offscreen" }
// Active reports whether the mode still holds the harness. It goes false after
// confirm or dismiss.
func (m *Mode) Active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return !m.done
}
// Command routes a surface command. start (re)runs the brief; confirm files the
// proposed action and finishes; dismiss finishes without writing.
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
switch name {
case "start":
return m.start()
case "confirm":
return m.confirm(ctx)
case "dismiss":
m.mu.Lock()
m.done = true
m.mu.Unlock()
return nil
default:
return fmt.Errorf("offscreen: unknown command %q", name)
}
}
// start assembles the brief and kicks off the brain asynchronously. A new call
// bumps the generation so a stale in-flight result is discarded.
func (m *Mode) start() error {
m.mu.Lock()
m.gen++
gen := m.gen
m.status = statusPending
m.proposal = nil
m.errMsg = ""
m.mu.Unlock()
brief := m.assembleBrief()
var p ai.OffscreenProposal
var err error
m.async.Run(proposeTimeout,
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
func() bool { return gen != m.gen },
func() {
if err != nil {
m.status = statusError
m.errMsg = err.Error()
return
}
m.status = statusProposed
m.proposal = &p
})
return nil
}
// confirm files the proposed action as a task and ends the mode. It errors if
// there is nothing to confirm yet.
func (m *Mode) confirm(ctx context.Context) error {
m.mu.Lock()
p := m.proposal
m.mu.Unlock()
if p == nil {
return errors.New("offscreen: nothing to confirm")
}
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
return err
}
m.mu.Lock()
m.status = statusDone
m.done = true
m.mu.Unlock()
return nil
}
// View projects the mode's state for surfaces. Always JSON-marshalable.
func (m *Mode) View() any {
m.mu.Lock()
defer m.mu.Unlock()
v := map[string]any{"status": m.status}
if m.proposal != nil {
v["next_action"] = m.proposal.NextAction
v["rationale"] = m.proposal.Rationale
}
if m.errMsg != "" {
v["error"] = m.errMsg
}
return v
}
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
func Factory(svc harness.Services) mode.Mode {
m := New(svc)
_ = m.start()
return m
}
+140
View File
@@ -0,0 +1,140 @@
package offscreen
import (
"context"
"errors"
"testing"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/tasks"
)
// stubBackend is a local fake ai.Backend; ai's package-internal fake is not
// reachable from this package, so we define our own here.
type stubBackend struct {
out string
err error
}
func (b stubBackend) Name() string { return "stub" }
func (b stubBackend) Run(context.Context, string) (string, error) {
return b.out, b.err
}
type fakeTasks struct {
created []string
}
func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil }
func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error {
f.created = append(f.created, t.Title)
return nil
}
func newTestMode(t *testing.T, ft *fakeTasks, backend ai.Backend) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Clock: func() time.Time { return time.Unix(0, 0) },
Notify: func() {},
}
return New(svc)
}
// waitStatus polls the mode's View until status reaches want, or fails after 2s.
func waitStatus(t *testing.T, m *Mode, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
v, _ := m.View().(map[string]any)
if v != nil && v["status"] == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("status never reached %q (last view: %+v)", want, m.View())
}
func okBackend() stubBackend {
return stubBackend{out: `{"next_action":"call mum","rationale":"people goal"}`}
}
func TestConfirmFilesExactlyOneTask(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
if len(ft.created) != 1 {
t.Fatalf("created %d tasks, want 1", len(ft.created))
}
if ft.created[0] != "call mum" {
t.Fatalf("created task title = %q, want %q", ft.created[0], "call mum")
}
if m.Active() {
t.Fatal("mode should be done after confirm")
}
}
func TestDismissWritesNothing(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
if len(ft.created) != 0 {
t.Fatalf("dismiss created %d tasks, want 0", len(ft.created))
}
if m.Active() {
t.Fatal("mode should be done after dismiss")
}
}
func TestProposeErrorSurfacesInView(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, stubBackend{err: errors.New("boom")})
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusError)
v, _ := m.View().(map[string]any)
if v == nil || v["error"] == nil || v["error"] == "" {
t.Fatalf("expected error field in view, got %+v", v)
}
if !m.Active() {
t.Fatal("mode should stay active on error so the user can retry or dismiss")
}
}
func TestConfirmWithoutProposalErrors(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
// No start → no proposal.
if err := m.Command(context.Background(), "confirm", nil); err == nil {
t.Fatal("confirm without a proposal should error")
}
if len(ft.created) != 0 {
t.Fatalf("confirm without proposal created %d tasks, want 0", len(ft.created))
}
}
func TestUnknownCommandErrors(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend())
if err := m.Command(context.Background(), "bogus", nil); err == nil {
t.Fatal("unknown command should error")
}
}