diff --git a/docs/superpowers/plans/2026-06-05-ambient-drift-coach.md b/docs/superpowers/plans/2026-06-05-ambient-drift-coach.md new file mode 100644 index 0000000..38fa88f --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-ambient-drift-coach.md @@ -0,0 +1,2098 @@ +# Ambient Drift Coach Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Keel an always-on "ambient drift coach" — a sentinel beside the one-mode harness that, when no session is declared, judges your recent window activity against the `~/owc` frame and surfaces drift to the status bar, a `notify-send` toast, and a web-UI banner. + +**Architecture:** A new `internal/ambient.Sentinel` taps the window-sensor stream (fanned out alongside the harness) and ticks on a configurable cadence. Each tick it assembles the frame brief (extracted into a shared `internal/frame` package), calls a new `ai.Service.AmbientDrift` brain method, and applies a two-stage surfacing discipline (status line on first drift, toast only when drift persists to the next tick). A new `internal/notify` effector wraps `notify-send`. Surfaces (status file, web banner) and a settings dial (`ambient_mode`, `ambient_cadence_secs`) complete the loop. The sentinel stays silent whenever a harness mode is active. + +**Tech Stack:** Go, gin (web), ActivityWatch (memory, already wired), X11 evidence sensor (already wired), vanilla JS/HTML for the UI. + +**Spec:** `docs/superpowers/specs/2026-06-05-ambient-drift-coach-design.md` + +--- + +## File Structure + +**New files:** +- `internal/notify/notify.go` — `Notifier` port + `notify-send` adapter + nop; `NewNotifier()` selects by `exec.LookPath`. +- `internal/notify/notify_test.go` — nop-path test. +- `internal/frame/frame.go` — `Assemble(ctx, know, tasks) string` + `Truncate` + `MaxBriefBytes`; the `~/owc` goals/life-domains/tasks brief (moved out of off-screen). +- `internal/frame/frame_test.go` — section presence, nil-port degradation, truncation. +- `internal/ai/ambient.go` — `AmbientDrift` method + prompt + `parseAmbientDrift`. +- `internal/ai/ambient_test.go` — success, backend error, parse table. +- `internal/ambient/sentinel.go` — the `Sentinel` (ring, evaluate, surfaces, snooze, config). +- `internal/ambient/sentinel_test.go` — the full behavioral matrix. + +**Modified files:** +- `internal/mode/offscreen/collect.go` — delegate brief assembly to `frame`. +- `internal/statusfile/statusfile.go` — render the ambient line when idle. +- `internal/statusfile/statusfile_test.go` — idle+line / idle+empty cases. +- `internal/settings/settings.go` — `AmbientMode` + `AmbientCadenceSecs` fields, constants, validator, defaults. +- `internal/settings/settings_test.go` — validation + defaults. +- `internal/web/web.go` — `ambient` field in state JSON, `/ambient/snooze` route, `SetAmbient`, `Broadcast`. +- `internal/web/web_test.go` — ambient field + snooze route. +- `internal/web/settings_handlers.go` — preserve blank ambient fields on POST (mirror `aw_url`). +- `internal/web/settings_handlers_test.go` — preserve + invalid-mode 400. +- `internal/web/static/index.html` — ambient banner container. +- `internal/web/static/app.js` — banner render + settings controls. +- `internal/web/static/app.css` — banner styling. +- `cmd/keeld/main.go` — build the notifier + sentinel, fan out evidence, wire surfaces and settings. + +--- + +## Task 1: `internal/notify` — notification effector + +**Files:** +- Create: `internal/notify/notify.go` +- Test: `internal/notify/notify_test.go` + +The OS "interrupt me" primitive, mirroring `internal/enforce`. One file, no build tags: `NewNotifier` returns the real `notify-send` adapter when the binary is on `PATH`, else a nop (so macOS/Windows/headless degrade automatically). + +- [ ] **Step 1: Write the failing test** + +```go +// internal/notify/notify_test.go +package notify + +import ( + "context" + "testing" +) + +// nopNotifier must be returned when notify-send is absent, and must never error. +func TestNopNotifierIsSilentAndSafe(t *testing.T) { + n := nopNotifier{} + if err := n.Notify(context.Background(), "title", "body"); err != nil { + t.Fatalf("nop Notify must return nil, got %v", err) + } +} + +// NewNotifier always returns a usable Notifier (never nil), whatever the host. +func TestNewNotifierNeverNil(t *testing.T) { + if NewNotifier() == nil { + t.Fatal("NewNotifier returned nil") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/notify/ -v` +Expected: FAIL — package/types don't exist (`undefined: nopNotifier`, `undefined: NewNotifier`). + +- [ ] **Step 3: Implement the package** + +```go +// internal/notify/notify.go + +// Package notify is Keel's desktop-notification effector: a best-effort +// "interrupt the user" primitive behind a Notifier port. On Linux with +// notify-send installed it pops a toast; everywhere else it is a silent nop. +// Mirrors internal/enforce: a pure OS primitive, all policy lives in callers. +package notify + +import ( + "context" + "fmt" + "os/exec" +) + +// Notifier shows a desktop notification. Best-effort: it returns an error for +// diagnostics, but callers never block on it and treat failure as "no toast." +type Notifier interface { + Notify(ctx context.Context, title, body string) error +} + +// NewNotifier returns the notify-send adapter when that binary is on PATH, else +// a nop. This makes a missing binary (or a non-Linux host) degrade to silence +// rather than an error. +func NewNotifier() Notifier { + if path, err := exec.LookPath("notify-send"); err == nil { + return sendNotifier{bin: path} + } + return nopNotifier{} +} + +// sendNotifier shells out to notify-send. The app name groups Keel's toasts. +type sendNotifier struct{ bin string } + +func (s sendNotifier) Notify(ctx context.Context, title, body string) error { + cmd := exec.CommandContext(ctx, s.bin, "--app-name=Keel", title, body) + if err := cmd.Run(); err != nil { + return fmt.Errorf("notify: notify-send: %w", err) + } + return nil +} + +// nopNotifier is the silent fallback. +type nopNotifier struct{} + +func (nopNotifier) Notify(context.Context, string, string) error { return nil } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/notify/ -v` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/ +git commit -m "feat(notify): notify-send effector with nop fallback" +``` + +--- + +## Task 2: `internal/frame` — shared frame assembler + +**Files:** +- Create: `internal/frame/frame.go` +- Test: `internal/frame/frame_test.go` + +Extract off-screen's brief assembly (today's tasks + `~/owc/goals-2026.md` + `~/owc/resources/bug-*.md`) into a shared, separately-tested package the sentinel will also use. This is a verbatim move of the logic in `internal/mode/offscreen/collect.go`, minus the proposal-history section (which stays in off-screen). + +- [ ] **Step 1: Write the failing test** + +```go +// internal/frame/frame_test.go +package frame + +import ( + "context" + "strings" + "testing" + + "keel/internal/knowledge" + "keel/internal/tasks" +) + +// fakeTasks is a tasks.Provider returning a fixed list. +type fakeTasks struct{ list []tasks.Task } + +func (f fakeTasks) Today(context.Context) ([]tasks.Task, error) { return f.list, nil } +func (f fakeTasks) Create(context.Context, tasks.Task) error { return nil } + +func TestAssembleListsTodaysTasks(t *testing.T) { + tp := fakeTasks{list: []tasks.Task{{Title: "write the keel spec"}}} + got := Assemble(context.Background(), nil, tp) + if !strings.Contains(got, "## Today's tasks") { + t.Fatalf("missing tasks header:\n%s", got) + } + if !strings.Contains(got, "write the keel spec") { + t.Fatalf("missing task title:\n%s", got) + } +} + +// With no knowledge source and no tasks, Assemble degrades to a short brief and +// never panics — both ports may be nil. +func TestAssembleToleratesNilPorts(t *testing.T) { + got := Assemble(context.Background(), nil, nil) + if !strings.Contains(got, "## Today's tasks") { + t.Fatalf("expected a tasks header even with no ports:\n%s", got) + } + if !strings.Contains(got, "(none)") { + t.Fatalf("expected (none) for absent tasks:\n%s", got) + } +} + +// A real FileSource pointed at a missing file contributes no goals section but +// does not error (knowledge.Load returns empty text for a missing file). +func TestAssembleWithEmptyKnowledge(t *testing.T) { + know := knowledge.NewFileSource("/nonexistent/keel-test-knowledge.md") + got := Assemble(context.Background(), know, fakeTasks{}) + if strings.Contains(got, "## Goals") { + t.Fatalf("empty knowledge should not emit a Goals section:\n%s", got) + } +} + +func TestTruncateClipsWithMarker(t *testing.T) { + in := strings.Repeat("x", MaxBriefBytes+100) + got := Truncate(in, MaxBriefBytes) + if len(got) > MaxBriefBytes+len("\n…(truncated)") { + t.Fatalf("truncate did not clip: len=%d", len(got)) + } + if !strings.HasSuffix(got, "(truncated)") { + t.Fatalf("missing truncation marker: %q", got[len(got)-20:]) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/frame/ -v` +Expected: FAIL — `undefined: Assemble`, `undefined: Truncate`, `undefined: MaxBriefBytes`. + +- [ ] **Step 3: Implement the package** (logic moved verbatim from `internal/mode/offscreen/collect.go`) + +```go +// internal/frame/frame.go + +// Package frame assembles the standing "what matters to Felix" brief — today's +// tasks plus the ~/owc goals and life-domain notes — that grounds a brain call. +// It is shared by the off-screen mode (which adds proposal history) and the +// ambient sentinel. Best-effort throughout: it never errors and tolerates nil +// ports and missing files. +package frame + +import ( + "context" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "keel/internal/knowledge" + "keel/internal/tasks" +) + +// goalsPath is the standing-goals note loaded for the brief. +const goalsPath = "~/owc/goals-2026.md" + +// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources. +const bugGlob = "bug-*.md" + +// MaxBriefBytes caps the assembled brief so the prompt stays bounded. +const MaxBriefBytes = 8 * 1024 + +// Assemble builds the frame brief from today's tasks, the standing goals note, +// and the life-domain notes. It never panics and never errors; nil ports and +// missing files degrade to a short, mostly-empty brief. +func Assemble(ctx context.Context, know knowledge.Source, tasksProvider tasks.Provider) string { + var b strings.Builder + + b.WriteString("## Today's tasks\n") + b.WriteString(briefTasks(ctx, tasksProvider)) + b.WriteString("\n") + + if goals := briefGoals(ctx, know); goals != "" { + b.WriteString("## Goals\n") + b.WriteString(goals) + b.WriteString("\n\n") + } + + if domains := briefLifeDomains(ctx, know); domains != "" { + b.WriteString("## Life domains\n") + b.WriteString(domains) + b.WriteString("\n") + } + + return Truncate(b.String(), MaxBriefBytes) +} + +// briefTasks lists today's task titles, or "(none)" when the port is absent, +// errors, or returns nothing. +func briefTasks(ctx context.Context, tp tasks.Provider) string { + if tp == nil { + return "(none)\n" + } + list, err := tp.Today(ctx) + 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 briefGoals(ctx context.Context, know knowledge.Source) string { + if know == nil { + return "" + } + p, err := know.Load(ctx, 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. Returns "" +// when the port is absent or no notes are readable. +func briefLifeDomains(ctx context.Context, know knowledge.Source) string { + if 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 := know.Load(ctx, 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") +} + +// Truncate clips s to at most max bytes with a marker, backing up to a rune +// boundary so it never splits a multibyte rune. +func Truncate(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)" +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/frame/ -v` +Expected: PASS (all four tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/frame/ +git commit -m "feat(frame): shared ~/owc frame assembler extracted for reuse" +``` + +--- + +## Task 3: Refactor off-screen to use `internal/frame` + +**Files:** +- Modify: `internal/mode/offscreen/collect.go` + +Replace off-screen's duplicated `briefTasks`/`briefGoals`/`briefLifeDomains`/`truncateBrief` with a call to `frame.Assemble`, keeping its own proposal-history section. Off-screen's existing tests (`Contains`-based, order-independent) must stay green. + +- [ ] **Step 1: Replace `collect.go` with the delegating version** + +Rewrite `internal/mode/offscreen/collect.go` to this exact content (drops the moved helpers and their now-unused imports `os`, `path/filepath`, `unicode/utf8`): + +```go +// internal/mode/offscreen/collect.go +package offscreen + +import ( + "context" + "fmt" + "strings" + "time" + + "keel/internal/frame" +) + +const ( + recentProposals = 5 // how many recent proposals to show + outcomeScan = 50 // how many outcome events to scan for correlation + recentWindow = 7 * 24 * time.Hour +) + +// assembleBrief builds the off-screen prompt context: the shared frame brief +// (today's tasks + ~/owc goals and life-domains) plus off-screen's own recent +// proposal history. Best-effort: never panics, never errors, tolerates nil +// ports and missing files. +func (m *Mode) assembleBrief() string { + base := frame.Assemble(context.Background(), m.know, m.tasks) + + hist := m.briefHistory() + if hist == "" { + return base + } + + var b strings.Builder + b.WriteString(base) + if !strings.HasSuffix(base, "\n") { + b.WriteString("\n") + } + b.WriteString("\n## Recently proposed\n") + b.WriteString(hist) + return frame.Truncate(b.String(), frame.MaxBriefBytes) +} + +// briefHistory renders recent proposals with their outcome, newest first and +// within recentWindow. Returns "" when there is no memory or no recent history. +func (m *Mode) briefHistory() string { + if m.mem == nil { + return "" + } + ctx := context.Background() + proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals) + if err != nil || len(proposals) == 0 { + return "" + } + taken := m.idSet(ctx, kindActionTaken) + dismissed := m.idSet(ctx, kindProposalDismissed) + now := m.now() + + var b strings.Builder + for _, ev := range proposals { + if now.Sub(ev.At) > recentWindow { + continue + } + id, _ := ev.Data["proposal_id"].(string) + action, _ := ev.Data["next_action"].(string) + if action == "" { + continue + } + outcome := "unactioned" + if taken[id] { + outcome = "confirmed" + } else if dismissed[id] { + outcome = "dismissed" + } + fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At))) + } + return b.String() +} + +// idSet reads recent events of a kind and returns their proposal_ids as a set. +func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool { + out := map[string]bool{} + evs, err := m.mem.Recent(ctx, kind, outcomeScan) + if err != nil { + return out + } + for _, e := range evs { + if id, _ := e.Data["proposal_id"].(string); id != "" { + out[id] = true + } + } + return out +} + +// relativeAge renders a coarse human age for a duration. +func relativeAge(d time.Duration) string { + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} +``` + +- [ ] **Step 2: Build and run off-screen tests** + +Run: `go test ./internal/mode/offscreen/ -v` +Expected: PASS. The history test (`Contains "Recently proposed"` / `"walk"` / `"confirmed"` / `"stretch"` / `"dismissed"`) and the empty-history test (no `"Recently proposed"`) both still hold; section order changed but those tests do not assert order. + +- [ ] **Step 3: Verify the whole module still builds** + +Run: `go build ./...` +Expected: no errors (confirms no dangling references to the removed helpers/imports). + +- [ ] **Step 4: Commit** + +```bash +git add internal/mode/offscreen/collect.go +git commit -m "refactor(offscreen): assemble frame via shared internal/frame" +``` + +--- + +## Task 4: `ai.AmbientDrift` — the frame-grounded brain method + +**Files:** +- Create: `internal/ai/ambient.go` +- Test: `internal/ai/ambient_test.go` + +A new method on `ai.Service`, mirroring `Nudge`: forgiving, returns `""` when on-track and a one-sentence line when drifting. Reuses the package's `extractJSON`, `ErrEmptyResponse`, `ErrNoJSON`. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/ai/ambient_test.go +package ai + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestServiceAmbientDriftSuccess(t *testing.T) { + fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`} + line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"}) + if err != nil { + t.Fatalf("ambient drift: %v", err) + } + if line == "" { + t.Fatal("expected a non-empty drift line") + } + if !strings.Contains(fb.gotPrompt, "ship keel") { + t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt) + } + if !strings.Contains(fb.gotPrompt, "YouTube - Brave") { + t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt) + } +} + +func TestServiceAmbientDriftBackendError(t *testing.T) { + fb := &fakeBackend{err: errors.New("boom")} + if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil { + t.Fatal("want backend error") + } +} + +func TestParseAmbientDrift(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr error + }{ + {"on track yields empty", `{"drifting": false, "message": ""}`, "", nil}, + {"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil}, + {"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil}, + {"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil}, + {"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil}, + {"empty response", " ", "", ErrEmptyResponse}, + {"no json", "no braces here", "", ErrNoJSON}, + {"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAmbientDrift(tt.in) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/ai/ -run AmbientDrift -v` +Expected: FAIL — `undefined: AmbientDrift`, `undefined: parseAmbientDrift`, `undefined: ErrInvalidAmbientDrift`. + +- [ ] **Step 3: Implement the method** (mirrors `internal/ai/nudge.go`) + +```go +// internal/ai/ambient.go +package ai + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// AmbientDriftJudge judges whether current activity serves the user's standing +// frame when NO task is declared. Like Nudge it takes primitives, not domain +// types, so ai stays a leaf. The returned string is a one-sentence advisory, or +// "" when the activity is plausibly on-track. +type AmbientDriftJudge interface { + AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) +} + +// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response. +var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift") + +// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend. +func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) { + out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles)) + if err != nil { + return "", err + } + return parseAmbientDrift(out) +} + +func buildAmbientPrompt(frame string, recentTitles []string) string { + return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame — goals, values, and life-domains — and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift. + +Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters. + +Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: +{"drifting": , "message": ""} + +Rules: +- drifting: true only if the recent activity serves none of the frame; false otherwise. +- message: one short sentence naming the drift. REQUIRED when drifting is true. + +## Frame +` + frame + ` + +## Recent window titles (oldest to newest) +` + strings.Join(recentTitles, "\n") +} + +type rawAmbientDrift struct { + Drifting bool `json:"drifting"` + Message string `json:"message"` +} + +// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields +// "". A drift with no message degrades to "" (silence) rather than an error: +// the ambient signal is advisory, so the safe degenerate is to say nothing — +// exactly like parseNudge. +func parseAmbientDrift(s string) (string, error) { + if strings.TrimSpace(s) == "" { + return "", ErrEmptyResponse + } + if strings.IndexByte(s, '{') < 0 { + return "", ErrNoJSON + } + jsonStr, err := extractJSON(s) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err) + } + var raw rawAmbientDrift + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err) + } + if !raw.Drifting { + return "", nil + } + return strings.TrimSpace(raw.Message), nil +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/ai/ -run AmbientDrift -v` +Expected: PASS. Then `go test ./internal/ai/ -v` to confirm no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ai/ambient.go internal/ai/ambient_test.go +git commit -m "feat(ai): AmbientDrift — frame-grounded drift judge" +``` + +--- + +## Task 5: `internal/ambient` — sentinel core (ring, Line, New, OnWindow) + +**Files:** +- Create: `internal/ambient/sentinel.go` +- Test: `internal/ambient/sentinel_test.go` + +The struct, constructor, the evidence-ingest ring, and `Line`. The decision logic (`evaluate`) lands in Task 6. The sentinel defines its own narrow `AmbientDriftJudge` interface so `*ai.Service` satisfies it without `ambient` importing `ai`. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/ambient/sentinel_test.go +package ambient + +import ( + "context" + "testing" + + "keel/internal/evidence" +) + +func snap(title, class string) evidence.WindowSnapshot { + return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}} +} + +func TestNewLineEmpty(t *testing.T) { + s := New(Deps{}, 0, "notify") + if s.Line() != "" { + t.Fatalf("fresh sentinel line = %q, want empty", s.Line()) + } +} + +func TestOnWindowRingDedupesAndCaps(t *testing.T) { + s := New(Deps{}, 0, "notify") + s.OnWindow(snap("a", "x")) + s.OnWindow(snap("a", "x")) // consecutive dup ignored + s.OnWindow(snap("b", "x")) + if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("ring = %v, want [a b]", got) + } + for i := 0; i < recentTitlesMax+5; i++ { + s.OnWindow(snap(string(rune('A'+i)), "x")) + } + if got := s.recentForTest(); len(got) != recentTitlesMax { + t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax) + } +} + +func TestOnWindowSkipsEmptyTitle(t *testing.T) { + s := New(Deps{}, 0, "notify") + s.OnWindow(snap("", "x")) + if got := s.recentForTest(); len(got) != 0 { + t.Fatalf("empty title must not enter ring, got %v", got) + } +} + +var _ = context.Background +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/ambient/ -v` +Expected: FAIL — `undefined: New`, `undefined: Deps`, etc. + +- [ ] **Step 3: Implement the core** + +```go +// internal/ambient/sentinel.go + +// Package ambient is Keel's always-on drift coach: a Sentinel that watches the +// window stream beside the one-mode harness and, when no mode is active, judges +// recent activity against the ~/owc frame and surfaces drift. It is not a mode — +// it runs continuously, including when the harness is idle. +package ambient + +import ( + "context" + "log" + "strings" + "sync" + "time" + + "keel/internal/evidence" + "keel/internal/frame" + "keel/internal/knowledge" + "keel/internal/memory" + "keel/internal/notify" + "keel/internal/tasks" +) + +// recentTitlesMax bounds the title ring (matches the focus mode's history cap). +const recentTitlesMax = 10 + +// evalTimeout bounds a single ambient brain call. +const evalTimeout = 30 * time.Second + +// defaultCadence is used when the configured cadence is non-positive. +const defaultCadence = 5 * time.Minute + +// kindAmbientNudge is the memory event recorded for a sustained ambient drift. +const kindAmbientNudge = "ambient_nudge" + +// Ambient mode dial values (mirror settings.Ambient*). +const ( + modeOff = "off" + modeStatus = "status" + modeNotify = "notify" +) + +// AmbientDriftJudge is the brain call the sentinel needs. *ai.Service satisfies +// it; declaring it here keeps ambient decoupled from the ai package. +type AmbientDriftJudge interface { + AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) +} + +// Deps are the ports the sentinel depends on. Knowledge and Tasks may be nil +// (frame.Assemble guards). Memory and Notifier may be nil (best-effort guards). +// ActiveMode reports the harness's active mode kind ("" = idle); nil counts as +// always-idle. Clock defaults to time.Now. +type Deps struct { + AI AmbientDriftJudge + Knowledge knowledge.Source + Tasks tasks.Provider + Notifier notify.Notifier + Memory memory.Store + Clock func() time.Time + ActiveMode func() string +} + +// Sentinel watches activity and surfaces ambient drift. +type Sentinel struct { + mu sync.Mutex + deps Deps + cadence time.Duration + mode string + + recent []string + lastWindow evidence.WindowSnapshot + line string + lastEvalTitles string + drifting bool + committed bool + snoozeUntil time.Time + onChange []func() +} + +// New builds a sentinel. A non-positive cadence falls back to defaultCadence; an +// empty mode falls back to notify. +func New(d Deps, cadence time.Duration, mode string) *Sentinel { + if d.Clock == nil { + d.Clock = time.Now + } + if cadence <= 0 { + cadence = defaultCadence + } + return &Sentinel{deps: d, cadence: cadence, mode: normalizeMode(mode)} +} + +func normalizeMode(m string) string { + if m == "" { + return modeNotify + } + return m +} + +// OnWindow ingests one sensor observation: it records the latest window and +// appends the title to the dedup-capped ring. Cheap and lock-bounded; it fires +// no brain call. +func (s *Sentinel) OnWindow(w evidence.WindowSnapshot) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastWindow = w + t := strings.TrimSpace(w.Title) + if t == "" { + return + } + if n := len(s.recent); n > 0 && s.recent[n-1] == t { + return + } + s.recent = append(s.recent, t) + if len(s.recent) > recentTitlesMax { + s.recent = s.recent[len(s.recent)-recentTitlesMax:] + } +} + +// Line returns the current ambient coaching line ("" when on-track, quiet, or +// snoozed) for the surfaces. +func (s *Sentinel) Line() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.line +} + +// AddOnChange registers a surface refresh fired when the line changes. +func (s *Sentinel) AddOnChange(f func()) { + s.mu.Lock() + s.onChange = append(s.onChange, f) + s.mu.Unlock() +} + +// fireOnChange notifies registered surfaces off the lock. +func (s *Sentinel) fireOnChange() { + s.mu.Lock() + fs := append([]func(){}, s.onChange...) + s.mu.Unlock() + for _, f := range fs { + if f != nil { + f() + } + } +} + +// recentForTest returns a copy of the ring (test-only). +func (s *Sentinel) recentForTest() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.recent...) +} + +// silence: keep log imported until evaluate (Task 6) uses it. +var _ = log.Printf +var _ = frame.MaxBriefBytes +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/ambient/ -v` +Expected: PASS (the three core tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/ambient/ +git commit -m "feat(ambient): sentinel core — evidence ring, Line, deps" +``` + +--- + +## Task 6: `internal/ambient` — evaluate, Run, Snooze, SetConfig + +**Files:** +- Modify: `internal/ambient/sentinel.go` +- Test: `internal/ambient/sentinel_test.go` + +The decision logic and the cadence loop. Tests drive `evaluate` directly (white-box, same package) with a fake clock and a scripted judge, so they are deterministic. + +- [ ] **Step 1: Write the failing tests** (append to `sentinel_test.go`) + +```go +// --- helpers --- + +type fakeJudge struct { + out []string // queued returns, consumed front-to-back + err error + calls int +} + +func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) { + f.calls++ + if f.err != nil { + return "", f.err + } + if len(f.out) == 0 { + return "", nil + } + r := f.out[0] + f.out = f.out[1:] + return r, nil +} + +type fakeNotifier struct { + calls int + body string +} + +func (f *fakeNotifier) Notify(_ context.Context, _, body string) error { + f.calls++ + f.body = body + return nil +} + +func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel { + return New(Deps{ + AI: j, + Notifier: n, + Memory: mem, + Clock: func() time.Time { return time.Unix(1_700_000_000, 0) }, + ActiveMode: func() string { return *active }, + }, time.Minute, mode) +} + +// memoryFake is a minimal memory.Store recorder. +type memoryFake struct { + events []memory.Event +} + +func (m *memoryFake) Record(_ context.Context, e memory.Event) error { + m.events = append(m.events, e) + return nil +} +func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil } + +// --- behavioural matrix --- + +func TestEvaluateOnTrackIsSilent(t *testing.T) { + j := &fakeJudge{out: []string{""}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeNotify, &active) + s.OnWindow(snap("main.go - code", "code")) + s.evaluate(context.Background()) + if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 { + t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events)) + } +} + +func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) { + j := &fakeJudge{out: []string{"deep in YouTube"}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + if s.Line() == "" { + t.Fatal("first drift must set the status line") + } + if n.calls != 0 { + t.Fatalf("first drift must NOT toast, got %d", n.calls) + } + if len(mem.events) != 0 { + t.Fatalf("first drift must not record memory yet, got %d", len(mem.events)) + } +} + +func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) { + j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + + s.evaluate(context.Background()) // first: line, no toast + s.evaluate(context.Background()) // second: sustained -> one toast + one memory + s.evaluate(context.Background()) // third: still drift -> NO second toast + + if n.calls != 1 { + t.Fatalf("sustained drift must toast exactly once, got %d", n.calls) + } + if len(mem.events) != 1 { + t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events)) + } + if mem.events[0].Kind != kindAmbientNudge { + t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge) + } +} + +func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) { + j := &fakeJudge{out: []string{"drift", "drift", ""}} + n := &fakeNotifier{} + active := "" + s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + s.evaluate(context.Background()) + s.evaluate(context.Background()) // on-track now + if s.Line() != "" { + t.Fatalf("return to on-track must clear the line, got %q", s.Line()) + } +} + +func TestEvaluateSilentWhenModeActive(t *testing.T) { + j := &fakeJudge{out: []string{"drift"}} + active := "focus" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + if j.calls != 0 { + t.Fatalf("an active mode must suppress the brain call, got %d", j.calls) + } + if s.Line() != "" { + t.Fatalf("an active mode must keep the line empty, got %q", s.Line()) + } +} + +func TestEvaluateStatusModeNeverToasts(t *testing.T) { + j := &fakeJudge{out: []string{"drift", "drift"}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeStatus, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + s.evaluate(context.Background()) + if n.calls != 0 { + t.Fatalf("status mode must never toast, got %d", n.calls) + } + if s.Line() == "" { + t.Fatal("status mode must still show the line") + } + if len(mem.events) != 1 { + t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events)) + } +} + +func TestEvaluateOffModeDoesNothing(t *testing.T) { + j := &fakeJudge{out: []string{"drift"}} + active := "" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + if j.calls != 0 { + t.Fatalf("off mode must not call the brain, got %d", j.calls) + } +} + +func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) { + j := &fakeJudge{out: []string{""}} // one on-track answer available + active := "" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("main.go - code", "code")) + s.evaluate(context.Background()) // calls=1, on-track + s.evaluate(context.Background()) // unchanged ring + on-track -> skip + if j.calls != 1 { + t.Fatalf("cheap gate must skip the second call, got %d", j.calls) + } +} + +func TestSnoozeMutesAndClears(t *testing.T) { + j := &fakeJudge{out: []string{"drift", "drift"}} + active := "" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) // line set + s.Snooze(time.Hour) + if s.Line() != "" { + t.Fatalf("snooze must clear the line, got %q", s.Line()) + } + callsBefore := j.calls + s.evaluate(context.Background()) // snoozed -> no call + if j.calls != callsBefore { + t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/ambient/ -v` +Expected: FAIL — `s.evaluate undefined`, `s.Snooze undefined`. + +- [ ] **Step 3: Implement `evaluate`, `Run`, `Snooze`, `SetConfig`** (append to `sentinel.go`; remove the two `var _ =` placeholder lines from Task 5) + +```go +// Run drives the cadence loop: every cadence it evaluates, until ctx is +// cancelled. It re-reads the cadence each cycle so SetConfig takes effect on the +// next tick. +func (s *Sentinel) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-time.After(s.currentCadence()): + s.evaluate(ctx) + } + } +} + +func (s *Sentinel) currentCadence() time.Duration { + s.mu.Lock() + defer s.mu.Unlock() + return s.cadence +} + +// SetConfig updates the cadence and mode live (next tick). +func (s *Sentinel) SetConfig(cadence time.Duration, mode string) { + s.mu.Lock() + if cadence > 0 { + s.cadence = cadence + } + s.mode = normalizeMode(mode) + s.mu.Unlock() +} + +// Snooze mutes the sentinel for d: it clears the current line and episode and +// suppresses evaluation until the window elapses. +func (s *Sentinel) Snooze(d time.Duration) { + s.mu.Lock() + s.snoozeUntil = s.deps.Clock().Add(d) + changed := s.line != "" + s.line = "" + s.drifting = false + s.committed = false + s.mu.Unlock() + if changed { + s.fireOnChange() + } +} + +// evaluate runs one decision cycle. See the spec's "Trigger discipline." +func (s *Sentinel) evaluate(ctx context.Context) { + s.mu.Lock() + if s.mode == modeOff { + s.mu.Unlock() + return + } + if s.deps.Clock().Before(s.snoozeUntil) { + s.mu.Unlock() + return + } + if s.activeModeLocked() != "" { + // A mode is coaching; stay silent and clear any stale line. + changed := s.clearLocked() + s.mu.Unlock() + if changed { + s.fireOnChange() + } + return + } + titles := append([]string(nil), s.recent...) + joined := strings.Join(titles, "\n") + // Cheap gate: nothing new to judge and we are on-track -> skip the brain call. + if joined == s.lastEvalTitles && s.line == "" { + s.mu.Unlock() + return + } + win := s.lastWindow + mode := s.mode + judge := s.deps.AI + s.mu.Unlock() + + if judge == nil { + return + } + cctx, cancel := context.WithTimeout(ctx, evalTimeout) + fr := frame.Assemble(cctx, s.deps.Knowledge, s.deps.Tasks) + msg, err := judge.AmbientDrift(cctx, fr, titles) + cancel() + + s.mu.Lock() + s.lastEvalTitles = joined + if err != nil { + s.mu.Unlock() + log.Printf("ambient: drift judge failed: %v", err) + return // never fabricate drift; leave the prior line intact + } + + if msg == "" { // on-track + changed := s.clearLocked() + s.mu.Unlock() + if changed { + s.fireOnChange() + } + return + } + + if !s.drifting { // new episode: show the line, no toast yet + s.line = msg + s.drifting = true + s.committed = false + s.mu.Unlock() + s.fireOnChange() + return + } + + // Sustained drift. Commit once: record memory, and toast in notify mode. + changed := s.line != msg + s.line = msg + if s.committed { + s.mu.Unlock() + if changed { + s.fireOnChange() + } + return + } + s.committed = true + now := s.deps.Clock() + mem := s.deps.Memory + notifier := s.deps.Notifier + s.mu.Unlock() + + if mem != nil { + ev := memory.Event{Kind: kindAmbientNudge, At: now, Data: map[string]any{ + "message": msg, "title": win.Title, "class": win.Class, + }} + if e := mem.Record(ctx, ev); e != nil { + log.Printf("ambient: memory record: %v", e) + } + } + if mode == modeNotify && notifier != nil { + if e := notifier.Notify(ctx, "Keel — drift", msg); e != nil { + log.Printf("ambient: notify: %v", e) + } + } + s.fireOnChange() +} + +// clearLocked resets line + episode state and reports whether the line changed. +// Caller holds mu. +func (s *Sentinel) clearLocked() bool { + changed := s.line != "" + s.line = "" + s.drifting = false + s.committed = false + return changed +} + +// activeModeLocked reads the active-mode kind, treating a nil hook as idle. +// Caller holds mu (the hook does no locking on the sentinel). +func (s *Sentinel) activeModeLocked() string { + if s.deps.ActiveMode == nil { + return "" + } + return s.deps.ActiveMode() +} +``` + +Also delete the two placeholder lines added in Task 5: + +```go +var _ = log.Printf +var _ = frame.MaxBriefBytes +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/ambient/ -v` +Expected: PASS (all matrix tests + the Task 5 core tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/ambient/ +git commit -m "feat(ambient): evaluate loop, two-stage surfacing, snooze, config" +``` + +--- + +## Task 7: `statusfile` — render the ambient line when idle + +**Files:** +- Modify: `internal/statusfile/statusfile.go` +- Test: `internal/statusfile/statusfile_test.go` + +When the harness is idle, show the ambient line (`⚠ `) instead of `idle`. `Render` gains an `ambientLine` parameter; the `Writer` reads it from a `func() string` source and re-renders on the sentinel's change signal. + +- [ ] **Step 1: Write the failing test** (add to `statusfile_test.go`) + +```go +func TestRenderIdleShowsAmbientLine(t *testing.T) { + got := Render(mode.Envelope{ActiveMode: ""}, "deep in YouTube, no goal", time.Unix(0, 0)) + if got != "⚠ deep in YouTube, no goal" { + t.Fatalf("idle+ambient render = %q", got) + } +} + +func TestRenderIdleNoAmbientLineIsIdle(t *testing.T) { + if got := Render(mode.Envelope{ActiveMode: ""}, "", time.Unix(0, 0)); got != "idle" { + t.Fatalf("idle+no-ambient render = %q, want idle", got) + } +} + +func TestRenderActiveModeIgnoresAmbientLine(t *testing.T) { + // A non-idle envelope renders its own mode, never the ambient line. + got := Render(mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "walk"}}, "ambient ignored", time.Unix(0, 0)) + if got == "ambient ignored" || got == "⚠ ambient ignored" { + t.Fatalf("active mode must not render the ambient line, got %q", got) + } +} +``` + +The existing tests call `Render(env, now)` — they must be updated to `Render(env, "", now)`. Update every existing `Render(` call site in `statusfile_test.go` to pass `""` as the new middle argument. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/statusfile/ -v` +Expected: FAIL — `Render` now needs three args (compile error until both the impl and the existing call sites are updated). + +- [ ] **Step 3: Update `Render` and the `Writer`** + +In `internal/statusfile/statusfile.go`, change `Render` to take the ambient line and use it in the idle branch: + +```go +// Render produces the single status line for the harness envelope, dispatching +// on the active mode. An idle harness ("") renders the ambient coaching line as +// "⚠ " when one is present, else "idle"; an unknown kind renders a generic +// fallback so a future mode degrades gracefully. +func Render(env mode.Envelope, ambientLine string, now time.Time) string { + switch env.ActiveMode { + case "": + if line := strings.TrimSpace(ambientLine); line != "" { + return "⚠ " + line + } + return "idle" + case "focus": + st, ok := env.Mode.(focus.State) + if !ok { + return "focus" + } + return renderFocus(st, now) + case "offscreen": + return renderOffscreen(env.Mode) + default: + return env.ActiveMode + } +} +``` + +Update the `Writer` to hold an ambient-line source and pass it to `Render`: + +```go +// Writer periodically renders the harness envelope (plus the ambient line) and +// writes it to a file, rewriting only when the line changes. +type Writer struct { + path string + state func() mode.Envelope + ambient func() string + now func() time.Time + wake chan struct{} + + last string + wrote bool +} + +// NewWriter builds a Writer for path, reading the harness envelope via state and +// the ambient coaching line via ambient (either may be supplied; a nil ambient +// is treated as "" by write). +func NewWriter(path string, state func() mode.Envelope, ambient func() string) *Writer { + return &Writer{path: path, state: state, ambient: ambient, now: time.Now, wake: make(chan struct{}, 1)} +} +``` + +And in `write()`, source the ambient line: + +```go +func (w *Writer) write() { + line := "" + if w.ambient != nil { + line = w.ambient() + } + rendered := Render(w.state(), line, w.now()) + if w.wrote && rendered == w.last { + return + } + if err := os.WriteFile(w.path, []byte(rendered), 0o644); err != nil { + log.Printf("statusfile: write %s: %v", w.path, err) + return + } + w.last = rendered + w.wrote = true +} +``` + +Add `"strings"` to the imports. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/statusfile/ -v` +Expected: PASS (new + updated existing tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/statusfile/ +git commit -m "feat(statusfile): render the ambient drift line when idle" +``` + +--- + +## Task 8: `settings` — `ambient_mode` and `ambient_cadence_secs` + +**Files:** +- Modify: `internal/settings/settings.go` +- Test: `internal/settings/settings_test.go` + +Two new fields, their constants/validator, env-seeded defaults. + +- [ ] **Step 1: Write the failing test** (add to `settings_test.go`) + +```go +func TestValidAmbientMode(t *testing.T) { + for _, m := range []string{AmbientOff, AmbientStatus, AmbientNotify} { + if !ValidAmbientMode(m) { + t.Fatalf("%q should be valid", m) + } + } + if ValidAmbientMode("loud") { + t.Fatal("loud should be invalid") + } + if ValidAmbientMode("") { + t.Fatal("empty should be invalid (callers default before validating)") + } +} + +func TestSeedFromEnvAmbientDefaults(t *testing.T) { + t.Setenv("KEEL_AMBIENT_MODE", "") + t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "") + s := SeedFromEnv() + if s.AmbientMode != AmbientNotify { + t.Fatalf("ambient_mode default = %q, want %q", s.AmbientMode, AmbientNotify) + } + if s.AmbientCadenceSecs != 300 { + t.Fatalf("ambient_cadence_secs default = %d, want 300", s.AmbientCadenceSecs) + } +} + +func TestSeedFromEnvAmbientFromEnv(t *testing.T) { + t.Setenv("KEEL_AMBIENT_MODE", "status") + t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "120") + s := SeedFromEnv() + if s.AmbientMode != "status" || s.AmbientCadenceSecs != 120 { + t.Fatalf("env not honored: mode=%q cadence=%d", s.AmbientMode, s.AmbientCadenceSecs) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/settings/ -v` +Expected: FAIL — `undefined: ValidAmbientMode`, `undefined: AmbientNotify`, missing fields. + +- [ ] **Step 3: Implement the fields, constants, validator, defaults** + +Add to `internal/settings/settings.go`: + +```go +// Ambient drift-coach surface levels. +const ( + AmbientOff = "off" + AmbientStatus = "status" + AmbientNotify = "notify" +) + +// ErrInvalidAmbientMode marks a settings value whose ambient_mode is unknown. +var ErrInvalidAmbientMode = errors.New("settings: invalid ambient mode") + +// ValidAmbientMode reports whether m is a known ambient surface level. The empty +// string is NOT valid; callers default it before validating. +func ValidAmbientMode(m string) bool { + switch m { + case AmbientOff, AmbientStatus, AmbientNotify: + return true + default: + return false + } +} +``` + +Add the fields to `Settings`: + +```go +type Settings struct { + AIBackend string `json:"ai_backend"` + MarvinCmd string `json:"marvin_cmd"` + KnowledgePath string `json:"knowledge_path"` + AWURL string `json:"aw_url"` + AmbientMode string `json:"ambient_mode"` + AmbientCadenceSecs int `json:"ambient_cadence_secs"` +} +``` + +Extend `SeedFromEnv` (add `"strconv"` to imports): + +```go +func SeedFromEnv() Settings { + backend := os.Getenv("KEEL_AI_BACKEND") + if backend == "" { + backend = "claude" + } + awURL := os.Getenv("KEEL_AW_URL") + if awURL == "" { + awURL = "http://localhost:5600" + } + ambientMode := os.Getenv("KEEL_AMBIENT_MODE") + if ambientMode == "" { + ambientMode = AmbientNotify + } + cadence := 300 + if v, err := strconv.Atoi(os.Getenv("KEEL_AMBIENT_CADENCE_SECS")); err == nil && v > 0 { + cadence = v + } + return Settings{ + AIBackend: backend, + MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"), + KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"), + AWURL: awURL, + AmbientMode: ambientMode, + AmbientCadenceSecs: cadence, + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/settings/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/settings/ +git commit -m "feat(settings): ambient_mode + ambient_cadence_secs with defaults" +``` + +--- + +## Task 9: `web` — ambient field in state, `/ambient/snooze`, wiring hooks + +**Files:** +- Modify: `internal/web/web.go` +- Test: `internal/web/web_test.go` + +Surface the ambient line in the SSE state envelope, add the snooze endpoint, and expose `SetAmbient` / `Broadcast` for `main` to wire. + +- [ ] **Step 1: Write the failing test** (add to `web_test.go`, which is `package web`) + +```go +func TestStateJSONIncludesAmbient(t *testing.T) { + h := harness.New(harness.Services{}, map[string]harness.Factory{}) + s := NewServer(h) + s.SetAmbient(func() string { return "deep in YouTube" }, func(time.Duration) {}) + js := s.stateJSON() + if !strings.Contains(js, `"ambient":"deep in YouTube"`) { + t.Fatalf("state JSON missing ambient field: %s", js) + } +} + +func TestAmbientSnoozeRoute(t *testing.T) { + h := harness.New(harness.Services{}, map[string]harness.Factory{}) + s := NewServer(h) + var snoozed time.Duration + s.SetAmbient(func() string { return "" }, func(d time.Duration) { snoozed = d }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/ambient/snooze", nil) + s.Router().ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("snooze status = %d, want 204", w.Code) + } + if snoozed != time.Hour { + t.Fatalf("snooze duration = %v, want 1h", snoozed) + } +} +``` + +Ensure `web_test.go` imports `net/http`, `net/http/httptest`, `strings`, `time`, and `keel/internal/harness` (add any missing). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/web/ -run 'Ambient|StateJSON' -v` +Expected: FAIL — `s.SetAmbient undefined`, no `/ambient/snooze` route, no `ambient` field. + +- [ ] **Step 3: Implement the web changes** + +In `internal/web/web.go`, add `"keel/internal/mode"` to imports and two fields to `Server`: + +```go +type Server struct { + h *harness.Harness + bcast *Broadcaster + + ambientLine func() string + ambientSnooze func(time.Duration) + + mu sync.Mutex + timer *time.Timer + + settingsMu sync.Mutex + settings settings.Settings + settingsPath string + applyFn func(settings.Settings) error + applyMu sync.Mutex +} +``` + +Add the setter and an exported broadcast trigger: + +```go +// SetAmbient injects the ambient sentinel's line accessor and snooze action, +// wired by main. Called once at startup before serving. +func (s *Server) SetAmbient(line func() string, snooze func(time.Duration)) { + s.ambientLine = line + s.ambientSnooze = snooze +} + +// Broadcast pushes the current state envelope to SSE subscribers. Exposed so the +// ambient sentinel's change signal can refresh the UI alongside harness changes. +func (s *Server) Broadcast() { s.broadcast() } +``` + +Replace `stateJSON` to embed the ambient line: + +```go +func (s *Server) stateJSON() string { + line := "" + if s.ambientLine != nil { + line = s.ambientLine() + } + payload := struct { + mode.Envelope + Ambient string `json:"ambient"` + }{Envelope: s.h.State(), Ambient: line} + data, _ := json.Marshal(payload) + return string(data) +} +``` + +Add the route in `Router()` (next to the other POSTs): + +```go + r.POST("/ambient/snooze", s.handleAmbientSnooze) +``` + +Add the handler: + +```go +// handleAmbientSnooze mutes the ambient coach for an hour. +func (s *Server) handleAmbientSnooze(c *gin.Context) { + if s.ambientSnooze != nil { + s.ambientSnooze(time.Hour) + } + c.Status(http.StatusNoContent) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/web/ -run 'Ambient|StateJSON' -v` then `go test ./internal/web/ -v` +Expected: PASS, no regressions. (The embedded-struct JSON yields `{"active_mode":"","ambient":"deep in YouTube"}`.) + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/web.go internal/web/web_test.go +git commit -m "feat(web): ambient line in state envelope + /ambient/snooze" +``` + +--- + +## Task 10: `web` settings handler + static UI — banner & controls + +**Files:** +- Modify: `internal/web/settings_handlers.go` +- Test: `internal/web/settings_handlers_test.go` +- Modify: `internal/web/static/index.html` +- Modify: `internal/web/static/app.js` +- Modify: `internal/web/static/app.css` + +Server: preserve blank ambient fields on POST (mirror `aw_url`). UI: the ambient banner (line + Snooze 1h + Start focus) and the settings controls. + +- [ ] **Step 1: Write the failing server test** (add to `settings_handlers_test.go`) + +```go +func TestPostSettingsPreservesBlankAmbientFields(t *testing.T) { + h := harness.New(harness.Services{}, map[string]harness.Factory{}) + s := NewServer(h) + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + current := settings.Settings{AIBackend: "claude", AmbientMode: "status", AmbientCadenceSecs: 120} + s.SetSettings(path, current, func(settings.Settings) error { return nil }) + + // A POST that omits the ambient fields must not blank them. + body := `{"ai_backend":"claude"}` + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.Router().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + saved, err := settings.Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if saved.AmbientMode != "status" || saved.AmbientCadenceSecs != 120 { + t.Fatalf("ambient fields not preserved: mode=%q cadence=%d", saved.AmbientMode, saved.AmbientCadenceSecs) + } +} +``` + +Ensure the test imports `path/filepath`, `strings`, `net/http`, `net/http/httptest`, `keel/internal/harness`, `keel/internal/settings`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/web/ -run PreservesBlankAmbient -v` +Expected: FAIL — the saved `ambient_mode` is blanked to `""` (no preservation yet). + +- [ ] **Step 3: Preserve blank ambient fields** in `handlePostSettings` + +In `internal/web/settings_handlers.go`, just after the existing `aw_url` preservation block: + +```go + // aw_url is not exposed in the settings form; preserve the persisted value + // so a UI save never blanks a configured AW URL. + if req.AWURL == "" { + req.AWURL = s.settings.AWURL + } + // Likewise preserve ambient fields when a caller omits them, so a partial + // POST never clobbers a configured ambient dial. + if req.AmbientMode == "" { + req.AmbientMode = s.settings.AmbientMode + } + if req.AmbientCadenceSecs <= 0 { + req.AmbientCadenceSecs = s.settings.AmbientCadenceSecs + } +``` + +- [ ] **Step 4: Run the server test to verify it passes** + +Run: `go test ./internal/web/ -run PreservesBlankAmbient -v` +Expected: PASS. + +- [ ] **Step 5: Add the banner container to `index.html`** + +In `internal/web/static/index.html`, inside `
`, immediately after the `

` line and before `
`: + +```html + +``` + +- [ ] **Step 6: Render the banner in `app.js`** + +Add an `updateAmbient` function and call it from `route`. After the `route` function definition (before `const es = new EventSource(...)`), add: + +```js +// updateAmbient shows the always-on drift banner whenever the server reports an +// ambient coaching line, regardless of the active mode. It clears when empty. +function updateAmbient(env) { + const el = document.getElementById('ambientBanner'); + const line = (env && env.ambient) || ''; + if (!line) { el.hidden = true; el.innerHTML = ''; return; } + el.hidden = false; + el.innerHTML = `⚠ ${esc(line)} + + + + `; + document.getElementById('ambientSnooze').onclick = () => post('/ambient/snooze'); + document.getElementById('ambientFocus').onclick = () => post('/mode/focus/start'); +} +``` + +Then update `route` to call it on every state push — change: + +```js +function route(env) { + const am = (env && env.active_mode) || ''; + if (am !== renderedMode) { + renderedMode = am; + renderedState = null; + if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } + } + if (am === '') return renderLauncher(); + if (am === 'focus') return renderFocus(env.mode || {}); + if (am === 'offscreen') return renderOffscreen(env.mode || {}); + view.textContent = am; // unknown mode: degrade visibly +} +``` + +to: + +```js +function route(env) { + const am = (env && env.active_mode) || ''; + updateAmbient(env); + if (am !== renderedMode) { + renderedMode = am; + renderedState = null; + if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } + } + if (am === '') return renderLauncher(); + if (am === 'focus') return renderFocus(env.mode || {}); + if (am === 'offscreen') return renderOffscreen(env.mode || {}); + view.textContent = am; // unknown mode: degrade visibly +} +``` + +- [ ] **Step 7: Add the settings controls in `app.js`** + +In `openSettings`, add the two controls to the modal markup, immediately after the Knowledge `path-row`/`browsePane` block and before `
`: + +```js + + + + +``` + +In the same `.then(s => {…})` block where the fields are populated, add: + +```js + document.getElementById('setAmbient').value = s.ambient_mode || 'notify'; + document.getElementById('setAmbientCadence').value = s.ambient_cadence_secs || 300; +``` + +In `saveSettings`, extend the body: + +```js +function saveSettings() { + const body = { + ai_backend: document.getElementById('setBackend').value, + marvin_cmd: document.getElementById('setMarvin').value.trim(), + knowledge_path: document.getElementById('setKnow').value.trim(), + ambient_mode: document.getElementById('setAmbient').value, + ambient_cadence_secs: parseInt(document.getElementById('setAmbientCadence').value, 10) || 300, + }; + post('/settings', body).then(r => { + if (r.ok) { closeSettings(); return; } + return r.json().then(e => { + document.getElementById('setError').textContent = e.error || 'save failed'; + }); + }); +} +``` + +- [ ] **Step 8: Style the banner in `app.css`** + +Append to `internal/web/static/app.css`: + +```css +.ambient-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 8px 0; + padding: 10px 14px; + border-radius: 10px; + background: #3a2a00; + color: #ffd479; + border: 1px solid #6b4f00; +} +.ambient-banner .ambient-line { font-weight: 600; } +.ambient-banner .ambient-actions { display: flex; gap: 8px; flex-shrink: 0; } +``` + +- [ ] **Step 9: Verify build and the full web package** + +Run: `go test ./internal/web/ -v` +Expected: PASS. (The JS/CSS/HTML are static assets embedded via `embed`; they are exercised manually in Task 11's smoke test, not unit-tested.) + +- [ ] **Step 10: Commit** + +```bash +git add internal/web/ +git commit -m "feat(web): ambient banner, snooze/focus actions, settings controls" +``` + +--- + +## Task 11: Wire the sentinel in `cmd/keeld/main.go` + +**Files:** +- Modify: `cmd/keeld/main.go` + +Build the notifier and sentinel from the base ports and settings, fan the evidence stream to both harness and sentinel, run the sentinel, and wire its line/snooze/onChange into the surfaces and the live-settings applier. + +- [ ] **Step 1: Add imports** + +Add to the import block in `cmd/keeld/main.go`: + +```go + "keel/internal/ambient" + "keel/internal/notify" +``` + +- [ ] **Step 2: Build the notifier and sentinel after the harness is created** + +Immediately after the `h := harness.New(...)` block and the focus-restore block (after the `srv := web.NewServer(h)` line is fine too, but the sentinel must exist before `srv.SetAmbient`), insert — placing it right before `srv := web.NewServer(h)`: + +```go + // Ambient drift coach: an always-on sentinel beside the harness. It reuses the + // base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a + // mode is active. Backend/port changes need a daemon restart to reach it; live + // settings only re-dial its cadence and mode (mirrors "active mode keeps its + // services"). + ambMode := cfg.AmbientMode + if ambMode == "" { + ambMode = settings.AmbientNotify + } + sentinel := ambient.New(ambient.Deps{ + AI: base.AI, + Knowledge: base.Knowledge, + Tasks: base.Tasks, + Notifier: notify.NewNotifier(), + Memory: base.Memory, + Clock: time.Now, + ActiveMode: func() string { return h.State().ActiveMode }, + }, time.Duration(cfg.AmbientCadenceSecs)*time.Second, ambMode) + log.Printf("ambient: mode=%s cadence=%ds", ambMode, cfg.AmbientCadenceSecs) +``` + +- [ ] **Step 3: Wire the sentinel into the web server** + +Right after `srv := web.NewServer(h)` and `srv.Init()`: + +```go + srv.SetAmbient(sentinel.Line, sentinel.Snooze) + sentinel.AddOnChange(srv.Broadcast) +``` + +- [ ] **Step 4: Re-dial the sentinel on live settings changes** + +Replace the existing `applyFn` with one that validates the ambient mode and re-dials the sentinel: + +```go + applyFn := func(s settings.Settings) error { + if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) { + return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode) + } + next, err := buildServices(s) + if err != nil { + return err + } + h.SetServices(next) + mode := s.AmbientMode + if mode == "" { + mode = settings.AmbientNotify + } + sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode) + return nil + } +``` + +- [ ] **Step 5: Pass the ambient line to the status writer** + +In the status-file block, change the `NewWriter` call and register the sentinel's change signal: + +```go + writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line) + h.AddOnChange(writer.Wake) + sentinel.AddOnChange(writer.Wake) + go writer.Run(context.Background()) + log.Printf("status: writing %s", statusPath) +``` + +- [ ] **Step 6: Fan the evidence stream and run the sentinel** + +Replace the evidence-source block: + +```go + // Feed the window sensor stream into BOTH the harness (for the active mode) + // and the ambient sentinel (always-on). On a headless box the source is a + // no-op, so both degrade gracefully without new X11 requirements. + src := evidence.NewSource() + go src.Watch(context.Background(), func(w evidence.WindowSnapshot) { + h.RecordWindow(w) + sentinel.OnWindow(w) + }) + go sentinel.Run(context.Background()) +``` + +- [ ] **Step 7: Build and run the full suite** + +Run: `go build ./... && go vet ./... && go test ./...` +Expected: build clean, vet clean, all packages PASS. + +- [ ] **Step 8: Manual smoke test** + +Run: `go run ./cmd/keeld` +Then verify: +- The UI loads at `http://localhost:7777`; no console errors. +- Open Settings (⚙): the **Ambient coach** select and **check every** field show current values; saving persists them (re-open to confirm). +- With `ambient_mode=notify` and a short cadence (e.g. set 30 via settings), sit on a clearly off-frame window (a video site) for two cadence ticks → a `notify-send` toast appears, the status bar shows `⚠ …`, and the web banner appears with **Snooze 1h** / **Start focus**. +- Click **Snooze 1h** → banner clears and no further toasts for the window. +- Start a focus session → the ambient banner/line stays silent for the session's duration (coexistence rule). + +Document the smoke result (pass/fail per bullet) in the commit body. + +- [ ] **Step 9: Commit** + +```bash +git add cmd/keeld/main.go +git commit -m "feat(keeld): wire the ambient drift coach sentinel" +``` + +--- + +## Final review + +After all tasks: dispatch a final code-reviewer over the whole branch (per subagent-driven-development), then use superpowers:finishing-a-development-branch. + +- [ ] `go test ./...` green, `go vet ./...` clean, `go build ./...` clean. +- [ ] Spec coverage: sentinel (T5/6), notify effector (T1), frame extraction (T2/3), `AmbientDrift` (T4), evidence fan-out + run (T11), status surface (T7), web banner + snooze (T9/10), settings dial (T8), coexistence + two-stage discipline + cheap gate + degradation all exercised by tests. +- [ ] Manual smoke (T11.8) passed.