aac3173874
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2099 lines
63 KiB
Markdown
2099 lines
63 KiB
Markdown
# 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": <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**
|
|
|
|
```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 (`⚠ <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
|
|
// "⚠ <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`:
|
|
|
|
```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 `<main id="app">`, immediately after the `<h1>…</h1>` line and before `<div class="card" id="view">`:
|
|
|
|
```html
|
|
<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:
|
|
|
|
```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 = `<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:
|
|
|
|
```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 `<div id="setError" …>`:
|
|
|
|
```js
|
|
<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:
|
|
|
|
```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.
|