Files
antidrift/docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md
T
2026-05-31 16:27:50 -04:00

16 KiB

M3 — Drift Interceptor — Design

Date: 2026-05-31

Purpose

M3 makes drift visible and interruptive while a commitment is Active. The daemon watches the focused window (the M1 evidence stream) and, when the user wanders off-task, surfaces a dismissible interrupt in the active view asking them to refocus, justify ("this is on task"), or end the session.

It uses cheap local matching first and the LLM only for the ambiguous cases, keeping the slow CLI off the common path. This adds the second live AI role — JudgeDrift — at the cortex layer: it judges at a decision point the state machine exposes (an active session observing a window), but it never forces a transition. Enforcement (minimizing/blocking) remains deferred to M8; M3's "friction" is UI-only.

The ambient Nudge role is deliberately out of scope here; it is the fast follow-on (M3.5).

Scope

In scope (M3):

  • Local allowed-context matching ported from legacy/src/context.rs into the evidence package (window-class + title-substring matching).
  • The coach (ai Coach, from M2) extended to also propose allowed window classes; the planning form shows them as an editable field.
  • A new DriftJudge AI role behind a leaf-preserving interface; Service implements it via the same CLI backends as M2.
  • Live drift judgment wired into the RecordWindow hot path: debounced (≤ 1 judgment / ~10s) and cached per window-class, run in a background goroutine, surfaced over SSE.
  • An override loop: "this is on task" appends the window-class to the session's allowed-context so it matches locally thereafter.
  • Active-view drift interrupt UI; POST /refocus and POST /ontask routes.
  • Allowed-context persisted in the snapshot (survives restart).

Out of scope:

  • The ambient Nudge role (M3.5).
  • Domain/URL and command matching. AllowedContext keeps those fields, but the X11 sensor only yields window class + title — no browser URLs or process args — so M3 matches on class + title only.
  • Any enforcement (window-minimize, blocking): that is M8.
  • Persisting the drift verdict. See "Persistence" — only allowed-context is durable; the verdict recomputes after restart.

Architecture

M3 extends the ports-and-adapters shape established in M1/M2. The ai package gains a second role (DriftJudge) but stays a leaf package; the evidence package gains pure matching logic; session.Controller orchestrates the debounced async judgment exactly as it orchestrates the M2 coach; the browser renders over the existing SSE stream.

The drift pipeline (per window observation, while Active)

window observation ──▶ local match against allowed-context?
                         │
              matched ───┴─── not matched
                 │                │
            on-task          debounce + per-class cache
         (clear drift)            │
                          fresh ──┴── cached
                            │           │
                   JudgeDrift (bg)   use cached verdict
                            │
                     verdict on_task / drifting
                            │
                    drift state ──▶ SSE ──▶ active-view interrupt

A local match short-circuits to on-task with no LLM call. This is authoritative: a window in an allowed class is treated as on-task even if the user is technically idling there. Only unmatched windows reach the judge.

evidence — local matching (ported from Rust)

New internal/evidence/context.go, porting legacy/src/context.rs:

// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
// M3 uses class + title only; domains/commands are matched by the helpers but
// have no data source yet.
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool

with helpers windowClassAllowed, windowTitleAllowed (and domainAllowed, commandAllowed ported for completeness/tests, unused on the live path):

  • class: trimmed, casefolded, exact match against WindowClasses.
  • title: trimmed, casefolded substring match against WindowTitleSubstrings.
  • domain: exact or subdomain (docs.github.com matches github.com), trailing-dot/whitespace/case normalized.
  • command: executable basename, casefolded.

MatchesAllowed returns true if class OR title matches. evidence may import domain (for AllowedContext) — domain is a pure leaf, so no cycle.

aiDriftJudge, leaf-preserving

The M2 review established that ai imports nothing from the app. To keep that, JudgeDrift takes primitives, not domain/evidence types — the controller formats the commitment and window into strings before calling.

// Verdict is the drift judge's call on a single window.
type Verdict struct {
    OnTask bool
    Reason string
}

// DriftJudge decides whether the current window is on-task for a commitment.
type DriftJudge interface {
    JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
}

Service (from M2) also implements DriftJudge:

func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)

It builds a strict-JSON prompt, calls backend.Run, and parses:

{"on_task": false, "reason": "Reddit is unrelated to drafting the report"}
  • buildDriftPrompt(commitment, class, title) — gives the model the commitment description and the current window, asks for ONLY the JSON object above.
  • parseVerdict(s string) (Verdict, error) — reuses extractJSON; unmarshals {on_task bool, reason string}; trims reason. A missing/empty body or unparseable output returns an error (sentinel ErrInvalidVerdict); the caller degrades by leaving drift state unchanged.

Coach extension (allowed window classes)

ai.Proposal gains a field; the coach prompt asks for it:

type Proposal struct {
    NextAction          string
    SuccessCondition    string
    TimeboxSecs         int64
    AllowedWindowClasses []string // NEW
}

Prompt JSON shape extends to: {"next_action":..., "success_condition":..., "timebox_minutes":..., "allowed_window_classes":["code","firefox"]}. parseProposal reads the new array (optional — absent/empty is valid; the user can fill it in). ProposalView (session) gains allowed_window_classes.

session.Controller — orchestration

New ephemeral + durable state on the controller:

// durable (persisted): the active session's allowed classes, mutable via override
allowedClasses []string

// ephemeral drift machinery
judge           ai.DriftJudge
driftStatus     string            // "idle" | "pending" | "ontask" | "drifting"
driftReason     string
driftGen        int
lastJudgedAt    time.Time
judgedClasses   map[string]ai.Verdict // per-class cache for this session

Injection: SetDriftJudge(ai.DriftJudge) (mirrors SetCoach). Nil judge ⇒ drift stays idle; local matching still runs (a matched window shows on-task).

Hot-path hook in RecordWindow (while holding the lock, after applyEvent, only when Active):

  1. Build domain.AllowedContext{WindowClasses: c.allowedClasses} and test MatchesAllowed(ac, class, title). On match ⇒ set driftStatus=ontask, clear reason. Done (no LLM). (Only WindowClasses is populated in M3, since the coach proposes classes; title substrings are matched by the helper but left empty.)
  2. Else consult per-class cache: if judgedClasses[class] exists ⇒ apply it (ontask/drifting + reason). Done.
  3. Else debounce: if now.Sub(lastJudgedAt) < driftDebounce (10s) ⇒ leave current state. Done.
  4. Else launch judgment: bump driftGen, capture gen, set lastJudgedAt=now, driftStatus=pending, capture judge + commitment text
    • class/title, then (after unlocking, per the M2 pattern) run the judge in a goroutine with a driftTimeout (30s) context.

Goroutine completion (re-acquire lock): discard if gen != driftGen or no longer Active (stale — commitment ended/changed). Else cache judgedClasses[class]=verdict; if class is still the current window's class, set driftStatus to ontask/drifting + reason. Unlock, notify().

All notify() calls fire with the mutex released — identical discipline to M2's RequestCoach and the existing focus path.

Override / dismiss:

// OnTask appends the current window class to the session allowed-context, clears
// drift, and persists. The class now matches locally and is never re-judged.
func (c *Controller) OnTask() error

// Refocus clears the current drift verdict without changing allowed-context.
// The same off-task class may be judged again later.
func (c *Controller) Refocus() error

Both return ErrNotActive outside the Active state. OnTask appends to allowedClasses, drops any cached drifting verdict for that class, sets driftStatus=ontask, and persists the snapshot.

Commitment start: StartManualCommitment gains an allowedClasses []string parameter, stored on the controller and persisted. Drift caches/state reset when a session starts and when it ends (End).

State projection

State gains a drift view, projected only while Active:

type DriftView struct {
    Status string `json:"status"`           // idle | pending | ontask | drifting
    Reason string `json:"reason,omitempty"`
}
// added to State:
//   Drift *DriftView `json:"drift,omitempty"`

CommitmentView is unchanged; ProposalView gains AllowedWindowClasses []string json:"allowed_window_classes,omitempty".

Persistence

store.Snapshot gains AllowedWindowClasses []string. persistLocked writes the controller's allowedClasses; New restores them when a live Active session is rebuilt. The drift verdict is NOT persisted — on restart it recomputes from the first post-restart window observation (≤ one debounce window). This avoids showing a stale "drifting" interrupt for a window the user has already navigated away from, at the cost of a brief idle state after restart. This is a deliberate refinement of "persist session state across restart".

web layer

  • commitmentRequest gains AllowedWindowClasses []string json:"allowed_window_classes"; handleCommitment passes them to StartManualCommitment.
  • POST /refocusctrl.Refocus(); POST /ontaskctrl.OnTask(). Both via the existing respond helper (so ErrNotActive maps to 400, success broadcasts).

UI (internal/web/static/index.html)

Planning view: add an "Allowed apps" input (comma-separated window classes), pre-filled from coach.proposal.allowed_window_classes when a proposal lands (same one-time, non-clobbering pre-fill as M2). The Start commitment POST includes the parsed list.

Active view: when state.drift.status === 'drifting', render an interrupt block above/around the timer:

⚠ Possible drift
<reason>
[ Back to task ]  [ This is on task ]  [ End session ]
  • Back to taskPOST /refocus
  • This is on taskPOST /ontask
  • End session → the existing POST /complete (Active → Review), same as the active view's current Complete button

status === 'pending' may show a subtle "checking…" hint; ontask/idle show nothing. The active view already rebuilds on SSE ticks and runs a countdown timer; the drift block must integrate without resetting the timer — apply the same partial-update care used for the planning coach (update the drift region without tearing down the countdown). The interrupt is non-modal (it cannot lock the user out — enforcement is M8).

Configuration

No new configuration. The drift judge reuses the M2 backend selected by ANTIDRIFT_AI_BACKEND; the daemon wires the single Service into both SetCoach and SetDriftJudge. Debounce (10s) and timeout (30s) are constants.

Error Handling and Degradation

Condition Result
Nil judge (unwired/misconfig) Local matching still runs; unmatched windows leave drift idle — never blocks
CLI failure / timeout / unparseable verdict Judgment discarded; drift state unchanged (no false "drifting"); logged server-side
Judge slow, user changes window Per-class cache + generation guard; stale results discarded
/refocus or /ontask outside Active ErrNotActive → HTTP 400

Drift judgment failures never fabricate a drift verdict; the safe default is "not drifting".

Package Layout Changes

Package Change
evidence New context.go (matching helpers + MatchesAllowed) + tests ported from legacy/src/context.rs
ai Verdict, DriftJudge, Service.JudgeDrift, buildDriftPrompt, parseVerdict, ErrInvalidVerdict; Proposal.AllowedWindowClasses + coach prompt/parse updates
session allowedClasses (persisted), drift machinery (judge, status, reason, gen, debounce, per-class cache); SetDriftJudge; RecordWindow hook; OnTask/Refocus; StartManualCommitment allowed-classes param; DriftView + State.Drift; ProposalView.AllowedWindowClasses; snapshot field
store Snapshot.AllowedWindowClasses
web commitmentRequest.AllowedWindowClasses; POST /refocus, POST /ontask
web/static/index.html planning "allowed apps" field; active-view drift interrupt; partial-update care
cmd/antidriftd ctrl.SetDriftJudge(service) alongside SetCoach

Testing Strategy

evidence: port the context.rs test table (class exact/casefold, title substring, domain exact/subdomain/normalization, command basename) plus MatchesAllowed (class-only match, title-only match, neither).

ai: parseVerdict (valid on_task true/false, chatty-wrapped, missing fields → error); Service.JudgeDrift over a fakeBackend; parseProposal now reads allowed_window_classes (present, absent, empty); arg/prompt building does not regress. No real CLI.

session (with a fake DriftJudge):

  • Local match ⇒ ontask, judge never called.
  • Unmatched ⇒ pending then drifting/ontask per the fake's verdict.
  • Per-class cache: second observation of a judged class does not call the judge again.
  • Debounce: rapid unmatched observations within 10s trigger at most one judge call (drive clock via the existing SetClock).
  • Stale generation: slow judge result discarded after the session ends / a new one starts (gate the fake on a channel, as in the M2 coach test).
  • OnTask appends the class, clears drift, and a subsequent observation of that class matches locally (no judge call); persisted across reload.
  • Refocus clears drift without mutating allowed-context.
  • Restart restores allowedClasses from the snapshot; drift starts idle.
  • Nil judge: unmatched window leaves drift idle, no panic.

web: /refocus and /ontask happy paths + outside-Active 400; commitment request carries allowed classes into the controller.

All tests use fakes; no test spawns a real CLI. go test -race ./... stays clean.

Definition of Done

  • evidence matching ported with tests.
  • ai DriftJudge + coach allowed-classes extension, with tests.
  • Controller drift pipeline (local-first, debounced, cached, async) with override/dismiss and persistence, race-clean.
  • /refocus, /ontask routes; planning allowed-apps field; active-view drift interrupt that doesn't disrupt the timer.
  • Daemon wires SetDriftJudge.
  • go test -race ./... and go vet ./... pass; manual smoke: start a session with an allowed class, switch to an unrelated app, see the interrupt, exercise both buttons.
  • README/roadmap note M3 complete.