Files
antidrift/docs/superpowers/plans/2026-06-05-ambient-drift-coach.md
2026-06-05 21:20:11 -04:00

63 KiB

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.goNotifier port + notify-send adapter + nop; NewNotifier() selects by exec.LookPath.
  • internal/notify/notify_test.go — nop-path test.
  • internal/frame/frame.goAssemble(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.goAmbientDrift 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.goAmbientMode + AmbientCadenceSecs fields, constants, validator, defaults.
  • internal/settings/settings_test.go — validation + defaults.
  • internal/web/web.goambient 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
// 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
// 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
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
// 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)
// 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
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):

// 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
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
// 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)
// 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": <true or false>, "message": "<short explanation, one sentence>"}

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
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
// 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
// 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
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)
// --- 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)
// 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:

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
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 (⚠ <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)
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:

// Render produces the single status line for the harness envelope, dispatching
// on the active mode. An idle harness ("") renders the ambient coaching line as
// "⚠ <line>" 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:

// 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:

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
git add internal/statusfile/
git commit -m "feat(statusfile): render the ambient drift line when idle"

Task 8: settingsambient_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)
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:

// 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:

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):

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
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)
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:

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:

// 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:

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):

	r.POST("/ambient/snooze", s.handleAmbientSnooze)

Add the handler:

// 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
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)
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:

		// 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 <main id="app">, immediately after the <h1>…</h1> line and before <div class="card" id="view">:

  <div id="ambientBanner" class="ambient-banner" hidden></div>
  • 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:

// 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 = `<span class="ambient-line">⚠ ${esc(line)}</span>
    <span class="ambient-actions">
      <button type="button" class="btn btn-ghost" id="ambientSnooze">Snooze 1h</button>
      <button type="button" class="btn btn-primary" id="ambientFocus">Start focus</button>
    </span>`;
  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:

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:

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 <div id="setError" …>:

        <label>Ambient coach</label>
        <select id="setAmbient">
          <option value="notify">notify (status + toast)</option>
          <option value="status">status only</option>
          <option value="off">off</option>
        </select>
        <label>Ambient check every (seconds)</label>
        <input id="setAmbientCadence" type="number" min="30" placeholder="300">

In the same .then(s => {…}) block where the fields are populated, add:

    document.getElementById('setAmbient').value = s.ambient_mode || 'notify';
    document.getElementById('setAmbientCadence').value = s.ambient_cadence_secs || 300;

In saveSettings, extend the body:

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:

.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
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:

	"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):

	// 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():

	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:

	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:

		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:

	// 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
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.