Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.rsinto theevidencepackage (window-class + title-substring matching). - The coach (
aiCoach, from M2) extended to also propose allowed window classes; the planning form shows them as an editable field. - A new
DriftJudgeAI role behind a leaf-preserving interface;Serviceimplements it via the same CLI backends as M2. - Live drift judgment wired into the
RecordWindowhot 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 /refocusandPOST /ontaskroutes. - Allowed-context persisted in the snapshot (survives restart).
Out of scope:
- The ambient
Nudgerole (M3.5). - Domain/URL and command matching.
AllowedContextkeeps 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.commatchesgithub.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.
// 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)— reusesextractJSON; unmarshals{on_task bool, reason string}; trimsreason. A missing/empty body or unparseable output returns an error (sentinelErrInvalidVerdict); 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):
- Build
domain.AllowedContext{WindowClasses: c.allowedClasses}and testMatchesAllowed(ac, class, title). On match ⇒ setdriftStatus=ontask, clear reason. Done (no LLM). (OnlyWindowClassesis populated in M3, since the coach proposes classes; title substrings are matched by the helper but left empty.) - Else consult per-class cache: if
judgedClasses[class]exists ⇒ apply it (ontask/drifting+ reason). Done. - Else debounce: if
now.Sub(lastJudgedAt) < driftDebounce(10s) ⇒ leave current state. Done. - Else launch judgment: bump
driftGen, capturegen, setlastJudgedAt=now,driftStatus=pending, capturejudge+ commitment text- class/title, then (after unlocking, per the M2 pattern) run the judge in a
goroutine with a
driftTimeout(30s) context.
- class/title, then (after unlocking, per the M2 pattern) run the judge in a
goroutine with a
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
commitmentRequestgainsAllowedWindowClasses []string json:"allowed_window_classes";handleCommitmentpasses them toStartManualCommitment.POST /refocus→ctrl.Refocus();POST /ontask→ctrl.OnTask(). Both via the existingrespondhelper (soErrNotActivemaps 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 /refocusThis is on task→POST /ontaskEnd session→ the existingPOST /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 ⇒
pendingthendrifting/ontaskper 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
clockvia the existingSetClock). - 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).
OnTaskappends the class, clears drift, and a subsequent observation of that class matches locally (no judge call); persisted across reload.Refocusclears drift without mutating allowed-context.- Restart restores
allowedClassesfrom the snapshot; drift startsidle. - 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
evidencematching ported with tests.aiDriftJudge+ coach allowed-classes extension, with tests.- Controller drift pipeline (local-first, debounced, cached, async) with override/dismiss and persistence, race-clean.
/refocus,/ontaskroutes; planning allowed-apps field; active-view drift interrupt that doesn't disrupt the timer.- Daemon wires
SetDriftJudge. go test -race ./...andgo 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.