From 4e167744a0df39c227c32350a7e58047e45bfe3a Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 16:27:50 -0400 Subject: [PATCH] Add M3 drift interceptor design spec Co-Authored-By: Claude Opus 4.8 --- .../2026-05-31-m3-drift-interceptor-design.md | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md diff --git a/docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md b/docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md new file mode 100644 index 0000000..0bfc6e5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md @@ -0,0 +1,362 @@ +# 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`: + +```go +// 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. + +### `ai` — `DriftJudge`, 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. + +```go +// 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`: + +```go +func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error) +``` + +It builds a strict-JSON prompt, calls `backend.Run`, and parses: + +```json +{"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: + +```go +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: + +```go +// 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:** + +```go +// 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**: + +```go +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 /refocus` → `ctrl.Refocus()`; `POST /ontask` → `ctrl.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 + +[ Back to task ] [ This is on task ] [ End session ] +``` + +- `Back to task` → `POST /refocus` +- `This is on task` → `POST /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.