Add M3 drift interceptor design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
<reason>
|
||||||
|
[ 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.
|
||||||
Reference in New Issue
Block a user