Files
antidrift/docs/superpowers/specs/2026-06-05-ambient-drift-coach-design.md
felixm 9d7786c3e6 docs: design the ambient drift coach (always-on sentinel)
The first Control loop / scheduler component (architecture §3): an
always-on sentinel beside the one-mode harness that judges activity
against the ~/owc frame when no session is declared, and surfaces drift
to the status bar, a notify-send toast, and a web-UI banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:03:10 -04:00

16 KiB

Ambient Drift Coach — Design

Date: 2026-06-05 Status: Approved, ready for implementation planning Scope: Give Keel an always-on presence. Today Keel only coaches inside a declared focus session; the moment you are just working, the harness goes idle, drops every window snapshot, and the status bar reads idle. This slice adds an ambient drift coach: a sentinel that watches the window stream continuously (even when no mode is active), judges your recent activity against the broad ~/owc frame (goals, values, life-domains) plus today's Marvin tasks, and — when you have clearly slid off what matters — surfaces a single coaching line to the status bar, a notify-send toast, and a web-UI banner you can act on.

This builds the Control loop / scheduler component from docs/keel-architecture.md §3, which today has zero implementation, and is the first thing that makes Keel show up unprompted (SOUL: "when my partner drifts, I name it").

Problem

The focus mode is already a capable proactive coach — local on-task matching, an LLM drift judge, a semantic "nudge" over recent window titles, surfaced to the status bar and web UI, all sensibly debounced. But every bit of it hangs off a declared Commitment. You must open the UI and start a session first. With no session:

  • harness.RecordWindow finds no active mode and silently drops the snapshot.
  • statusfile.Render of an empty envelope returns idle.
  • No brain call ever happens; Keel is blind and mute.

So the common case — Felix working without ceremonially declaring a task — gets nothing. The fix is an observer that runs regardless of the active mode and is grounded in the standing frame instead of a per-session commitment.

Approach (and the one real fork)

The ambient coach is not a mode. It is an always-on sentinel that runs beside the one-mode harness. Two harness invariants make "ambient as a mode" the wrong shape:

  1. The harness hosts exactly one mode and drops evidence when idle — an ambient watcher must run continuously, including when idle.
  2. harness.Start returns ErrBusy if a different mode is active — an ambient mode would either block you from starting focus, or need a special auto-yield carve-out that erodes the single-mode contract.

A sentinel sidesteps both and matches §3, where the control loop is a distinct component beside the harness. Coexistence rule: when any harness mode is active (h.State().ActiveMode != ""), the sentinel stays silent — focus mode already owns coaching during a declared session, so there is never double coaching.

Components

Each unit has one responsibility, a defined interface, and is testable alone.

1. internal/notify — notification effector (new)

The OS-level "interrupt me" primitive, mirroring enforce.Guard.

  • Interface: Notifier interface { Notify(ctx context.Context, title, body string) error }
  • Linux adapter: shells notify-send <title> <body>. Best-effort: returns an error for diagnostics; callers never block on it.
  • Constructor: NewNotifier() Notifier returns the real adapter when the notify-send binary is found on PATH (via exec.LookPath), else a nop. On non-Linux it is a nop. This means a missing binary degrades to "no toast," never a failure.
  • Nop: Notify returns nil, does nothing.
  • Depends on: os/exec only. Leaf package.

2. internal/frame — shared frame assembler (new; DRY extraction)

The "what matters to Felix" brief, today duplicated inside off-screen's collect.go. Extracting it removes the duplication and gives the sentinel the same grounding off-screen uses.

  • Interface: Assemble(ctx context.Context, know knowledge.Source, tasks tasks.Provider) string returns a bounded markdown brief with three sections: Today's tasks (from tasks.Today), Goals (~/owc/goals-2026.md via knowledge.Load), and Life domains (every ~/owc/resources/bug-*.md via knowledge.Load).
  • Best-effort: never panics, never errors; tolerates nil ports and missing files, degrading to a short, mostly-empty brief. Same truncation cap (maxBriefBytes) and rune-safe clipping off-screen uses today.
  • Refactor: off-screen's briefTasks / briefGoals / briefLifeDomains are replaced by a call to frame.Assemble; off-screen keeps its own proposal-history section (briefHistory) prepended around it. Off-screen's existing tests must stay green.
  • Depends on: knowledge, tasks. Imports nothing from ai/mode.

3. internal/aiAmbientDrift method (new; mirrors Nudge)

The brain call grounded in the frame, not a commitment.

  • Method: func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) returns "" when the activity plausibly serves the frame (on-track), and a one-sentence coaching line when it is a slide.
  • Prompt: an "ambient coach" prompt stating the user has not declared a task; it is handed the frame and the recent window-title sequence and asked whether the activity serves any of what matters, forgivingly — legitimate breaks, rest, and unplanned-but-useful work are on-track. Output is ONLY JSON: {"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}.
  • Parse (parseAmbientDrift): reuses extractJSON and the shared ErrEmptyResponse / ErrNoJSON sentinels. drifting:false"". drifting:true with a message → the message. drifting:true with no message → "" (silence) — the ambient signal is advisory, so the safe degenerate is to say nothing, exactly like parseNudge.
  • Depends on: the existing Backend. ai stays a leaf (primitives only).

4. internal/ambient — the Sentinel (new; the meat)

Always-on observer: consume evidence → tick on cadence → judge → surface.

Construction: New(deps) where deps carry *ai.Service, knowledge.Source, tasks.Provider, notify.Notifier, memory.Store, clock func() time.Time, and activeMode func() string (reads h.State().ActiveMode for the coexistence rule). Config: cadence time.Duration and mode (off | status | notify).

State (under one mutex): recentTitles []string (consecutive-dedup ring, capped like focus's recentTitlesMax), lastWindow evidence.WindowSnapshot, line string (current drift line; "" when on-track), lastEvalTitles (snapshot of the ring at the last brain call), wasDrifting bool, episodeNotified bool, snoozeUntil time.Time, gen int, and onChange []func().

Methods:

  • OnWindow(snap evidence.WindowSnapshot) — called from the evidence fan-out. Updates lastWindow, appends the title to the ring (skip consecutive dup, cap). Cheap and lock-bounded; fires no brain call.
  • Run(ctx context.Context) — cadence loop: a ticker every cadence calls evaluate(); returns on ctx cancel.
  • evaluate() — the decision (see Trigger discipline below).
  • Line() string — current ambient line for the surfaces ("" when on-track, quiet, or snoozed).
  • Snooze(d time.Duration) — sets snoozeUntil = clock()+d; clears the line and episode flags; fires onChange. While snoozed, evaluate() returns early.
  • SetConfig(cadence time.Duration, mode string) — live settings update.
  • AddOnChange(f func()) — register a surface refresh (status wake, web broadcast). Fired whenever the line changes.

Depends on: ai, frame, notify, memory, evidence, and a activeMode closure. It does not import harness (closure injection keeps it decoupled and testable).

5. Wiring — cmd/keeld/main.go

  • Build the notifier (notify.NewNotifier()) and the sentinel from settings (cadence, mode) and the shared ports (the same ai.Service, knowledge, tasks, memory, clock used to build harness.Services), with activeMode: func() string { return h.State().ActiveMode }.
  • Fan the evidence stream to both the harness and the sentinel:
    go src.Watch(ctx, func(w evidence.WindowSnapshot) {
        h.RecordWindow(w)
        sentinel.OnWindow(w)
    })
    
  • Run the sentinel: go sentinel.Run(ctx).
  • Surface refresh: register both the status writer's Wake and the web broadcast with sentinel.AddOnChange(...), so a new ambient line re-renders the bar and pushes over SSE promptly.
  • Live settings: applyFn (the existing POST /settings re-wire) also calls sentinel.SetConfig(...) from the new settings, so the dial takes effect without a restart.

Trigger discipline (aggressive, but not trigger-happy)

A notify-send toast on a false positive is far more irritating than a stale status line, so evaluate() is conservative and brain-call-thrifty:

  1. If mode == off → return.
  2. If snoozed (clock() < snoozeUntil) → return.
  3. If activeMode() != "" → a mode is coaching; clear the line (if set), fire onChange, return. (Coexistence rule.)
  4. Cheap gate: if the ring is unchanged since lastEvalTitles and we are currently on-track (line == "") → skip the brain call. This naturally pauses calls during steady deep work and while AFK (no new titles arrive in either case), while still re-checking an unresolved drift.
  5. Assemble the frame (frame.Assemble) and call ai.AmbientDrift(ctx, frame, recentTitles) under a timeout.
  6. Apply the result:
    • On-track (""): if a line was set, clear it and reset wasDrifting=false, episodeNotified=false; fire onChange. Otherwise no-op.
    • Drifting (msg):
      • New episode (!wasDrifting): set line=msg, wasDrifting=true, episodeNotified=false; fire onChange. The status bar / banner show immediately, but no toast yet.
      • Sustained (already wasDrifting on this consecutive eval): record an ambient_nudge event to memory ({ "message": msg, "title": lastWindow.Title, "class": lastWindow.Class }), and — if mode == notify and !episodeNotified — fire exactly one notify-send toast and set episodeNotified=true. Further sustained ticks do not re-toast.
    • On a brain error, leave the prior line intact and log — never fabricate drift (same discipline as Nudge).
  7. Update lastEvalTitles to the current ring.

Net effect with the default ~5-minute cadence: a 40-second glance at Twitter never pops a toast (it resolves before the second tick); a sustained ~10-minute slide shows on the bar at the first tick and pops one toast at the second. Memory records only sustained drifts, independent of the surface mode.

Surfaces

  • Status bar (internal/statusfile): Render gains the current ambient line (passed in by the Writer, which reads sentinel.Line). When the envelope is idle (ActiveMode == ""): a non-empty line renders ⚠ <line>; an empty line renders idle as today. Non-idle envelopes are unchanged. The Writer adds an ambientLine func() string source and sentinel.AddOnChange(writer.Wake) drives prompt re-render; the existing minute ticker still applies.
  • Web UI (internal/web + static/app.js): the served state gains an "ambient": "<line>" field (read from sentinel.Line), pushed over the existing SSE channel via sentinel.AddOnChange(broadcast). app.js renders a banner when the field is non-empty: the coaching line plus two controls — Snooze 1h (POST /ambient/snooze) and Start focus (reuses the existing start-session affordance so a nudge converts into a declared session). The banner clears automatically when the line goes empty.
  • New endpoint: POST /ambient/snoozesentinel.Snooze(1 * time.Hour). Returns 204. No body needed in v1.

Config (the agency-vs-cage dial, §8)

settings.Settings gains two fields (env-seedable, mirroring the existing ones):

  • ambient_mode (KEEL_AMBIENT_MODE): off | status | notify. Default notify. Validated in the settings applier (like ai_backend); an unknown value is rejected with a wrapped sentinel so POST /settings can map it to 400.
  • ambient_cadence_secs (KEEL_AMBIENT_CADENCE_SECS): integer seconds between evaluations. Default 300. A non-positive value falls back to the default.

The web settings form gains a select (ambient_mode) and a number input (ambient_cadence_secs); applyFn pushes both into sentinel.SetConfig.

Error handling & graceful degradation

  • No notify-send binary / non-Linux: nop notifier; status line and banner still work.
  • Brain error / timeout: AmbientDrift returns an error; the sentinel logs and leaves the prior line intact. Never fabricates drift.
  • AW down: memory is already the nop store; ambient_nudge writes are dropped silently.
  • knowledge / tasks errors: frame.Assemble degrades to a partial brief (best-effort), never errors.
  • Headless / no X11: the evidence source is a no-op, so OnWindow is never called, the ring stays empty, the cheap gate skips every brain call, and the sentinel is silent. No new X11 requirement.
  • Nothing in the sentinel can crash the daemon — every external call is best-effort and lock-bounded.

Testing

  • internal/ambient (fake clock, fake ai with scriptable AmbientDrift returns and a call counter, fake notify.Notifier recording calls, fake tasks/knowledge, activeMode stub, memory.Fake):
    • on-track → no line, no toast, no memory.
    • single drift tick → line set, no toast, no memory yet.
    • two consecutive drift ticks → line set, exactly one toast, one ambient_nudge event.
    • third consecutive drift tick → still one toast (once per episode).
    • back on-track → line cleared, flags reset.
    • activeMode() != "" → no eval, line cleared.
    • mode == status → drift sets the line but never toasts (memory still records the sustained drift).
    • mode == off → nothing happens, no brain call.
    • snooze → no eval / no line during the window.
    • cheap gate → unchanged ring + on-track → assert the fake ai call count does not increase.
  • internal/notify: NewNotifier with notify-send absent → nop; Notify returns nil, no panic. (The real shell path is not unit-tested.)
  • internal/ai: parseAmbientDrift — on-track→"", drift+msg→msg, drift with no msg→"", empty→ErrEmptyResponse, no-JSON→ErrNoJSON.
  • internal/frame: Assemble with fake ports — sections present; nil ports and missing files degrade without error; truncation honored. Off-screen's existing tests stay green after the refactor.
  • internal/statusfile: idle + ambient line → ⚠ …; idle + empty line → idle; non-idle envelopes unaffected.
  • internal/web: served state includes ambient; POST /ambient/snooze routes to Snooze and returns 204.
  • internal/settings: ambient_mode validation (good values accepted, unknown rejected with the sentinel); cadence default on non-positive.

Out of scope (deliberate, for a later slice)

  • AFK precision via the AW afk bucket. v1 uses the cheap-gate heuristic (no new titles ⇒ no call), which covers both AFK and deep-focus well enough. Reading AW's input-derived afk bucket to prove presence is a future collector.
  • Ambient enforcement (window-minimize). The sentinel is advisory only; minimizing windows with no declared session would be far too cagey. Enforcement stays a focus-session decision.
  • Trend / spiral detection over the accumulating ambient_nudge history (§7). This slice produces that history; reading it for cross-time patterns is the next increment.
  • Richer banner actions beyond snooze + start-focus (e.g. "I'm on it", per-domain mute).

Open questions

  • Default cadence. 300 s is a starting guess; may want tuning after living with it. Configurable, so cheap to change.
  • Snooze duration. Fixed 1 h in v1; a picker is a later refinement.