# M3 — Drift Interceptor Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** While a commitment is Active, watch the focused window, judge drift (cheap local match first, LLM only for ambiguous cases), and surface a dismissible active-view interrupt with refocus / "this is on task" / end actions. **Architecture:** Extends the M1/M2 ports-and-adapters shape. `evidence` gains pure allowed-context matching (ported from `legacy/src/context.rs`). `ai` gains a second leaf-preserving role, `DriftJudge` (primitives only, no domain imports). `session.Controller` orchestrates a debounced, per-class-cached, async judgment on the `RecordWindow` hot path — same goroutine + generation-guard + notify-after-unlock discipline as M2's `RequestCoach`. The browser renders the interrupt over the existing SSE stream. **Tech Stack:** Go 1.26, Gin, SSE, stdlib `testing` (no testify), `go test -race ./...`. --- ## File Structure | File | Responsibility | Task | | ---- | -------------- | ---- | | `internal/evidence/context.go` (create) | Pure allowed-context matching helpers + `MatchesAllowed` | 1 | | `internal/evidence/context_test.go` (create) | Port of the Rust matching test table | 1 | | `internal/ai/verdict.go` (create) | `Verdict`, `ErrInvalidVerdict`, `parseVerdict` | 2 | | `internal/ai/coach.go` (modify) | `DriftJudge` interface, `Service.JudgeDrift`, `buildDriftPrompt` | 2 | | `internal/ai/verdict_test.go` (create) | `parseVerdict` + `JudgeDrift` over a fake backend | 2 | | `internal/ai/proposal.go` (modify) | `Proposal.AllowedWindowClasses` + parse | 3 | | `internal/ai/coach.go` (modify) | coach prompt asks for `allowed_window_classes` | 3 | | `internal/ai/proposal_test.go` (modify) | allowed-classes present / absent / empty | 3 | | `internal/store/store.go` (modify) | `Snapshot.AllowedWindowClasses` | 4 | | `internal/session/session.go` (modify) | drift fields/constants, `DriftView`+`State.Drift`, `ProposalView` field, persist/restore, `StartManualCommitment` param, `SetDriftJudge`, `OnTask`, `Refocus`, drift reset | 4 | | `internal/web/web.go` (modify) | keep build green: pass `nil` to new signature (real wiring in Task 6) | 4 | | `internal/session/session_test.go` (modify) | update existing `StartManualCommitment` callers; `OnTask`/`Refocus` tests | 4 | | `internal/session/session.go` (modify) | `RecordWindow` drift pipeline: `evaluateDriftLocked`, `applyVerdictLocked`, async judge | 5 | | `internal/session/session_test.go` (modify) | fake `DriftJudge`: local match, unmatched, cache, debounce, stale gen, nil judge | 5 | | `internal/web/web.go` (modify) | `commitmentRequest.AllowedWindowClasses`, `POST /refocus`, `POST /ontask` | 6 | | `internal/web/web_test.go` (modify/create) | route happy paths + outside-Active 400 + commitment carries classes | 6 | | `internal/web/static/index.html` (modify) | planning "allowed apps" field; active-view drift interrupt | 7 | | `cmd/antidriftd/main.go` (modify) | `ctrl.SetDriftJudge(svc)` alongside `SetCoach` | 8 | | `README.md` (modify) | M3 status note | 9 | --- ## Task 1: `evidence` local matching (ported from Rust) **Files:** - Create: `internal/evidence/context.go` - Test: `internal/evidence/context_test.go` The `evidence` package may import `domain` (a pure leaf), so there is no cycle. Match logic mirrors `legacy/src/context.rs` exactly: class is exact casefolded; title is casefolded substring; domain is exact-or-subdomain with trailing-dot/whitespace/case normalization; command is the casefolded executable basename. `MatchesAllowed` is the live entry point used by the controller; `domainAllowed`/`commandAllowed` are ported for completeness and exercised by tests but have no live data source in M3. - [ ] **Step 1: Write the failing test** Create `internal/evidence/context_test.go`: ```go package evidence import ( "testing" "antidrift/internal/domain" ) func TestWindowClassMatchesCaseAndTrim(t *testing.T) { ctx := domain.AllowedContext{WindowClasses: []string{" code "}} if !windowClassAllowed(ctx, " Code ") { t.Fatal("class should match case-insensitively after trim") } if windowClassAllowed(ctx, "firefox") { t.Fatal("unrelated class must not match") } if windowClassAllowed(ctx, " ") { t.Fatal("empty candidate must not match") } } func TestWindowTitleMatchesSubstring(t *testing.T) { ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}} if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") { t.Fatal("title substring should match after trim/casefold") } if windowTitleAllowed(ctx, "random video") { t.Fatal("unrelated title must not match") } } func TestDomainMatchesExactAndSubdomain(t *testing.T) { ctx := domain.AllowedContext{Domains: []string{" GitHub.COM. "}} if !domainAllowed(ctx, "github.com") { t.Fatal("exact domain should match") } if !domainAllowed(ctx, " DOCS.GITHUB.COM. ") { t.Fatal("subdomain should match after normalization") } if domainAllowed(ctx, "evilgithub.com") { t.Fatal("suffix-without-dot must not match") } } func TestCommandMatchesExecutableBasename(t *testing.T) { ctx := domain.AllowedContext{Commands: []string{"cargo"}} if !commandAllowed(ctx, "/home/felixm/.cargo/bin/cargo test") { t.Fatal("basename of full path should match") } if !commandAllowed(ctx, "cargo") { t.Fatal("bare command should match") } if commandAllowed(ctx, "/usr/bin/git status") { t.Fatal("unrelated command must not match") } } func TestMatchesAllowedClassOrTitle(t *testing.T) { ctx := domain.AllowedContext{ WindowClasses: []string{"code"}, WindowTitleSubstrings: []string{"antidrift"}, } if !MatchesAllowed(ctx, "Code", "anything") { t.Fatal("class match should satisfy MatchesAllowed") } if !MatchesAllowed(ctx, "firefox", "my AntiDrift tab") { t.Fatal("title match should satisfy MatchesAllowed") } if MatchesAllowed(ctx, "firefox", "youtube") { t.Fatal("neither match must be off-task") } if MatchesAllowed(domain.AllowedContext{}, "code", "antidrift") { t.Fatal("empty context matches nothing") } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `go test ./internal/evidence/ -run TestMatchesAllowed -v` Expected: FAIL — `undefined: MatchesAllowed` (compile error). - [ ] **Step 3: Write the implementation** Create `internal/evidence/context.go`: ```go package evidence import ( "strings" "antidrift/internal/domain" ) // MatchesAllowed reports whether a window (class/title) is on-task per ctx. // M3 matches on window class and title only; the domain and command helpers are // ported for completeness and tests but have no live data source yet. func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool { return windowClassAllowed(ctx, class) || windowTitleAllowed(ctx, title) } func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool { candidate = normalizeCasefolded(candidate) if candidate == "" { return false } for _, allowed := range ctx.WindowClasses { if normalizeCasefolded(allowed) == candidate { return true } } return false } func windowTitleAllowed(ctx domain.AllowedContext, candidate string) bool { candidate = normalizeCasefolded(candidate) if candidate == "" { return false } for _, allowed := range ctx.WindowTitleSubstrings { a := normalizeCasefolded(allowed) if a != "" && strings.Contains(candidate, a) { return true } } return false } func domainAllowed(ctx domain.AllowedContext, candidate string) bool { candidate = normalizeDomain(candidate) if candidate == "" { return false } for _, allowed := range ctx.Domains { a := normalizeDomain(allowed) if a == "" { continue } if candidate == a { return true } if strings.HasSuffix(candidate, a) && strings.HasSuffix(candidate[:len(candidate)-len(a)], ".") { return true } } return false } func commandAllowed(ctx domain.AllowedContext, candidate string) bool { candidate = executableBasename(candidate) if candidate == "" { return false } for _, allowed := range ctx.Commands { if executableBasename(allowed) == candidate { return true } } return false } func normalizeDomain(v string) string { return strings.ToLower(strings.TrimRight(strings.TrimSpace(v), ".")) } func normalizeCasefolded(v string) string { return strings.ToLower(strings.TrimSpace(v)) } // executableBasename returns the lowercased basename of the first whitespace- // separated token (the executable), splitting on both / and \. func executableBasename(command string) string { fields := strings.Fields(command) if len(fields) == 0 { return "" } exe := fields[0] if i := strings.LastIndexAny(exe, `/\`); i >= 0 { exe = exe[i+1:] } return strings.ToLower(strings.TrimSpace(exe)) } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `go test ./internal/evidence/ -v` Expected: PASS (all matching tests). - [ ] **Step 5: Commit** ```bash git add internal/evidence/context.go internal/evidence/context_test.go git commit -m "Port allowed-context matching to evidence package Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 2: `ai` — `DriftJudge` role **Files:** - Create: `internal/ai/verdict.go` - Modify: `internal/ai/coach.go` - Test: `internal/ai/verdict_test.go` `ai` stays a leaf package: `JudgeDrift` takes **primitive strings**, not `domain`/`evidence` types. `parseVerdict` reuses the existing `extractJSON` and the `ErrEmptyResponse`/`ErrNoJSON` sentinels from `proposal.go`. A drifting verdict (`on_task: false`) with no reason is unusable for the UI, so it is rejected as `ErrInvalidVerdict`. - [ ] **Step 1: Write the failing test** Create `internal/ai/verdict_test.go`: ```go package ai import ( "context" "errors" "strings" "testing" ) func TestParseVerdictOnTask(t *testing.T) { v, err := parseVerdict(`{"on_task": true, "reason": ""}`) if err != nil { t.Fatalf("parse: %v", err) } if !v.OnTask { t.Fatalf("expected on-task, got %+v", v) } } func TestParseVerdictDriftingChatty(t *testing.T) { v, err := parseVerdict("Sure! here is the call:\n{\"on_task\": false, \"reason\": \"Reddit is unrelated\"}\nhope that helps") if err != nil { t.Fatalf("parse: %v", err) } if v.OnTask || v.Reason != "Reddit is unrelated" { t.Fatalf("bad verdict: %+v", v) } } func TestParseVerdictDriftingNeedsReason(t *testing.T) { if _, err := parseVerdict(`{"on_task": false}`); !errors.Is(err, ErrInvalidVerdict) { t.Fatalf("want ErrInvalidVerdict for reasonless drift, got %v", err) } } func TestParseVerdictEmpty(t *testing.T) { if _, err := parseVerdict(" "); !errors.Is(err, ErrEmptyResponse) { t.Fatalf("want ErrEmptyResponse, got %v", err) } } func TestParseVerdictNoJSON(t *testing.T) { if _, err := parseVerdict("I cannot help"); !errors.Is(err, ErrNoJSON) { t.Fatalf("want ErrNoJSON, got %v", err) } } func TestServiceJudgeDrift(t *testing.T) { fb := &fakeBackend{out: `{"on_task": false, "reason": "YouTube is off-task"}`} v, err := NewService(fb).JudgeDrift(context.Background(), "write the report", "firefox", "YouTube") if err != nil { t.Fatalf("judge: %v", err) } if v.OnTask || v.Reason == "" { t.Fatalf("bad verdict: %+v", v) } if !strings.Contains(fb.gotPrompt, "write the report") || !strings.Contains(fb.gotPrompt, "YouTube") { t.Fatalf("prompt should embed commitment and window: %s", fb.gotPrompt) } } func TestServiceJudgeDriftBackendError(t *testing.T) { fb := &fakeBackend{err: errors.New("boom")} if _, err := NewService(fb).JudgeDrift(context.Background(), "x", "c", "t"); err == nil { t.Fatal("want backend error") } } ``` (`fakeBackend` already exists in `internal/ai/coach_test.go`.) - [ ] **Step 2: Run the test to verify it fails** Run: `go test ./internal/ai/ -run TestParseVerdict -v` Expected: FAIL — `undefined: parseVerdict` / `ErrInvalidVerdict`. - [ ] **Step 3: Write `verdict.go`** Create `internal/ai/verdict.go`: ```go package ai import ( "encoding/json" "errors" "fmt" "strings" ) // Verdict is the drift judge's call on a single window. type Verdict struct { OnTask bool Reason string } // ErrInvalidVerdict marks a parseable-but-unusable judge response. var ErrInvalidVerdict = errors.New("ai: invalid verdict") type rawVerdict struct { OnTask bool `json:"on_task"` Reason string `json:"reason"` } // parseVerdict extracts and validates a Verdict from raw CLI output. It reuses // extractJSON and the shared empty/no-JSON sentinels. A drifting verdict with no // reason is unusable for the UI and is rejected as ErrInvalidVerdict. func parseVerdict(s string) (Verdict, error) { if strings.TrimSpace(s) == "" { return Verdict{}, ErrEmptyResponse } if strings.IndexByte(s, '{') < 0 { return Verdict{}, ErrNoJSON } jsonStr, err := extractJSON(s) if err != nil { return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err) } var raw rawVerdict if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err) } v := Verdict{OnTask: raw.OnTask, Reason: strings.TrimSpace(raw.Reason)} if !v.OnTask && v.Reason == "" { return Verdict{}, ErrInvalidVerdict } return v, nil } ``` - [ ] **Step 4: Add `DriftJudge` + `JudgeDrift` + `buildDriftPrompt` to `coach.go`** Append to `internal/ai/coach.go` (the file already imports `context`): ```go // DriftJudge decides whether the current window is on-task for a commitment. // It takes primitives, not domain/evidence types, so ai stays a leaf package. type DriftJudge interface { JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error) } // JudgeDrift makes Service satisfy DriftJudge over the same backend as Coach. func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error) { out, err := s.backend.Run(ctx, buildDriftPrompt(commitment, windowClass, windowTitle)) if err != nil { return Verdict{}, err } return parseVerdict(out) } func buildDriftPrompt(commitment, class, title string) string { return `You are a focus monitor. The user committed to a task. Decide whether their CURRENT window is on-task or a distraction. Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: {"on_task": , "reason": ""} Rules: - on_task: true if the window plausibly serves the commitment, false if it is a distraction. - reason: one short sentence. REQUIRED when on_task is false. Commitment: ` + commitment + ` Current window class: ` + class + ` Current window title: ` + title } ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `go test ./internal/ai/ -v` Expected: PASS (verdict tests + existing coach/proposal/backend tests). - [ ] **Step 6: Commit** ```bash git add internal/ai/verdict.go internal/ai/coach.go internal/ai/verdict_test.go git commit -m "Add DriftJudge AI role with verdict parsing Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 3: Coach proposes allowed window classes **Files:** - Modify: `internal/ai/proposal.go` - Modify: `internal/ai/coach.go` - Test: `internal/ai/proposal_test.go` The coach now also proposes window classes. The field is optional: absent or empty is valid (the user fills it in). Blank entries are dropped. - [ ] **Step 1: Write/extend the failing test** Add to `internal/ai/proposal_test.go`: ```go func TestParseProposalReadsAllowedClasses(t *testing.T) { p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20,"allowed_window_classes":["code"," firefox ",""]}`) if err != nil { t.Fatalf("parse: %v", err) } if len(p.AllowedWindowClasses) != 2 || p.AllowedWindowClasses[0] != "code" || p.AllowedWindowClasses[1] != "firefox" { t.Fatalf("classes wrong (blanks should drop, trim applied): %#v", p.AllowedWindowClasses) } } func TestParseProposalAllowedClassesOptional(t *testing.T) { p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20}`) if err != nil { t.Fatalf("parse: %v", err) } if len(p.AllowedWindowClasses) != 0 { t.Fatalf("absent field should yield empty slice, got %#v", p.AllowedWindowClasses) } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `go test ./internal/ai/ -run TestParseProposalReadsAllowedClasses -v` Expected: FAIL — `p.AllowedWindowClasses undefined`. - [ ] **Step 3: Add the field and parse it in `proposal.go`** In `internal/ai/proposal.go`, extend `Proposal`: ```go type Proposal struct { NextAction string SuccessCondition string TimeboxSecs int64 AllowedWindowClasses []string } ``` Extend `rawProposal`: ```go type rawProposal struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxMinutes int64 `json:"timebox_minutes"` AllowedWindowClasses []string `json:"allowed_window_classes"` } ``` Replace the final `return` of `parseProposal` (the success path) with: ```go classes := make([]string, 0, len(raw.AllowedWindowClasses)) for _, cls := range raw.AllowedWindowClasses { if t := strings.TrimSpace(cls); t != "" { classes = append(classes, t) } } return Proposal{ NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60, AllowedWindowClasses: classes, }, nil ``` - [ ] **Step 4: Update the coach prompt in `coach.go`** In `buildPrompt`, change the JSON shape line and add a rule. Replace: ```go {"next_action": "", "success_condition": "", "timebox_minutes": } ``` with: ```go {"next_action": "", "success_condition": "", "timebox_minutes": , "allowed_window_classes": [""]} ``` and add a rule line after the `timebox_minutes` rule: ```go - allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty. ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `go test ./internal/ai/ -v` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add internal/ai/proposal.go internal/ai/coach.go internal/ai/proposal_test.go git commit -m "Extend coach proposal with allowed window classes Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 4: Controller drift state, persistence, overrides **Files:** - Modify: `internal/store/store.go` - Modify: `internal/session/session.go` - Modify: `internal/web/web.go` (keep build green only) - Test: `internal/session/session_test.go` This task adds all drift fields, the `DriftView`/`State.Drift` projection, the `ProposalView` allowed-classes field, allowed-context persistence/restore, the new `StartManualCommitment` parameter, `SetDriftJudge`, and the `OnTask`/`Refocus` overrides. The `RecordWindow` pipeline that *launches* judgments comes in Task 5. To keep `go build ./...` green after the signature change, update the `web.go` call site to pass `nil` (real wiring lands in Task 6) and the existing test callers likewise. - [ ] **Step 1: Add the snapshot field** In `internal/store/store.go`, add to `Snapshot`: ```go AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` ``` - [ ] **Step 2: Write the failing tests** Add to `internal/session/session_test.go`: ```go func TestOnTaskRequiresActive(t *testing.T) { c, _ := newTestController(t) if err := c.OnTask(); !errors.Is(err, ErrNotActive) { t.Fatalf("OnTask outside Active should error, got %v", err) } if err := c.Refocus(); !errors.Is(err, ErrNotActive) { t.Fatalf("Refocus outside Active should error, got %v", err) } } func TestStartCommitmentPersistsAllowedClasses(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") first, err := New(path) if err != nil { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil { t.Fatalf("start: %v", err) } second, err := New(path) if err != nil { t.Fatalf("reload: %v", err) } if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { t.Fatalf("allowed classes not restored: %#v", got) } } func TestOnTaskAppendsCurrentClass(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() _ = c.StartManualCommitment("a", "b", 25*time.Minute, nil) c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}}) if err := c.OnTask(); err != nil { t.Fatalf("ontask: %v", err) } got := c.AllowedClassesForTest() if len(got) != 1 || got[0] != "slack" { t.Fatalf("OnTask should append current class, got %#v", got) } st := c.State() if st.Drift == nil || st.Drift.Status != "ontask" { t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift) } } ``` Add a tiny test-only accessor (kept with the controller since the field is unexported) in `session.go`: ```go // AllowedClassesForTest exposes the session allowed classes for tests. func (c *Controller) AllowedClassesForTest() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.allowedClasses...) } ``` - [ ] **Step 3: Run the tests to verify they fail** Run: `go test ./internal/session/ -run 'TestOnTask|TestStartCommitmentPersistsAllowedClasses' -v` Expected: FAIL — undefined `ErrNotActive`, `OnTask`, `AllowedClassesForTest`, and a signature mismatch on `StartManualCommitment`. - [ ] **Step 4: Add fields, constants, and projections to `session.go`** Add `"strings"` to the import block. Add constants/sentinel near `coachTimeout`: ```go const ( driftDebounce = 10 * time.Second driftTimeout = 30 * time.Second ) const ( driftIdle = "idle" driftPending = "pending" driftOnTask = "ontask" driftDrifting = "drifting" ) var ErrNotActive = errors.New("session: only available while a commitment is active") ``` Add fields to `Controller` (after the coach fields): ```go allowedClasses []string // durable: the active session's allowed window classes judge ai.DriftJudge driftStatus string driftReason string driftGen int lastJudgedAt time.Time judgedClasses map[string]ai.Verdict ``` Add the projection types: ```go // DriftView is the active-only drift projection. type DriftView struct { Status string `json:"status"` Reason string `json:"reason,omitempty"` } ``` Add to `State`: ```go Drift *DriftView `json:"drift,omitempty"` ``` Add the allowed-classes field to `ProposalView`: ```go AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` ``` - [ ] **Step 5: Wire projection, persistence, restore, and reset** In `stateLocked`, inside the coach block where `cv.Proposal` is built, add the field: ```go cv.Proposal = &ProposalView{ NextAction: c.coachProposal.NextAction, SuccessCondition: c.coachProposal.SuccessCondition, TimeboxSecs: c.coachProposal.TimeboxSecs, AllowedWindowClasses: c.coachProposal.AllowedWindowClasses, } ``` In `stateLocked`, after the evidence block and before `return st`, add the active-only drift projection: ```go if c.runtimeState == domain.RuntimeActive { status := c.driftStatus if status == "" { status = driftIdle } st.Drift = &DriftView{Status: status, Reason: c.driftReason} } ``` In `persistLocked`, after setting `snap.SessionID`, persist allowed classes: ```go snap.AllowedWindowClasses = c.allowedClasses ``` In `New`, inside the `if c.runtimeState == domain.RuntimeActive && s.SessionID != ""` block (before/after `replayStats`), restore allowed classes: ```go c.allowedClasses = s.AllowedWindowClasses ``` Add a reset helper (mirrors `resetCoachLocked`): ```go // resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu. func (c *Controller) resetDriftLocked() { c.driftStatus = driftIdle c.driftReason = "" c.driftGen++ c.lastJudgedAt = time.Time{} c.judgedClasses = map[string]ai.Verdict{} } ``` - [ ] **Step 6: Add `SetDriftJudge`, change `StartManualCommitment`, clear on `End`** Add the injector (mirrors `SetCoach`): ```go // SetDriftJudge injects the AI drift judge. A nil judge keeps local matching // working; unmatched windows simply stay idle. func (c *Controller) SetDriftJudge(j ai.DriftJudge) { c.mu.Lock() c.judge = j c.mu.Unlock() } ``` Change the signature and body of `StartManualCommitment`. New signature: ```go func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error { ``` Inside, after `c.resetCoachLocked()`, add: ```go c.allowedClasses = append([]string(nil), allowedClasses...) c.resetDriftLocked() ``` In `End`, after clearing `c.stats = nil`, add: ```go c.allowedClasses = nil c.resetDriftLocked() ``` - [ ] **Step 7: Add `OnTask` and `Refocus`** ```go // OnTask appends the current window class to the session allowed-context, clears // drift, drops any cached verdict for that class, and persists. The class now // matches locally and is never re-judged. func (c *Controller) OnTask() error { c.mu.Lock() defer c.mu.Unlock() if c.runtimeState != domain.RuntimeActive { return ErrNotActive } var class string if c.stats != nil { class = c.stats.Current.Class } if strings.TrimSpace(class) != "" { ac := domain.AllowedContext{WindowClasses: c.allowedClasses} if !evidence.MatchesAllowed(ac, class, "") { c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class)) } delete(c.judgedClasses, class) } c.driftStatus = driftOnTask c.driftReason = "" return c.persistLocked() } // Refocus clears the current drift verdict without changing allowed-context, and // drops the cached verdict for the current class so it may be judged again later. func (c *Controller) Refocus() error { c.mu.Lock() defer c.mu.Unlock() if c.runtimeState != domain.RuntimeActive { return ErrNotActive } if c.stats != nil { delete(c.judgedClasses, c.stats.Current.Class) } c.driftStatus = driftIdle c.driftReason = "" return c.persistLocked() } ``` - [ ] **Step 8: Keep the build green — update existing `StartManualCommitment` callers** In `internal/web/web.go`, `handleCommitment` currently calls: ```go err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second) ``` Change to (temporary `nil`; real wiring in Task 6): ```go err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, nil) ``` In `internal/session/session_test.go`, every existing call to `StartManualCommitment(...)` with three args gains a trailing `, nil`. There are nine such calls (in `TestHappyPathDrivesStates`, `TestStartCommitmentRejectsInvalidInput`, `TestStateRestoresFromSnapshot`, `TestRestoredCommitmentKeepsDeadline`, `TestAccumulatesBucketsAndSwitches`, `TestNoAccountingOutsideActive`, `TestCrashReplayRebuildsStats`, `TestEndWritesAuditSummary`, and `TestLeavingPlanningClearsCoach`). Confirm the full set with: Run: `grep -rn "StartManualCommitment(" internal/` Add a trailing `, nil` to each three-arg call (skip the new four-arg calls added in this plan). - [ ] **Step 9: Run the package tests** Run: `go build ./... && go test ./internal/session/ ./internal/web/ -v` Expected: PASS, build clean. - [ ] **Step 10: Commit** ```bash git add internal/store/store.go internal/session/session.go internal/web/web.go internal/session/session_test.go git commit -m "Add drift state, persistence, and override controls to controller Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 5: Drift pipeline on the `RecordWindow` hot path **Files:** - Modify: `internal/session/session.go` - Test: `internal/session/session_test.go` Local match first (authoritative on-task, no LLM). Unmatched windows consult the per-class cache, then the debounce window, then launch an async judgment with a generation guard — the M2 `RequestCoach` discipline: the goroutine is launched **after** the mutex is released, and every `notify()` fires with the mutex released. A failed/timed-out judgment never fabricates "drifting"; it reverts the optimistic `pending` to the prior state. - [ ] **Step 1: Write the failing tests** Add to `internal/session/session_test.go` (imports: ensure `sync/atomic` is present): ```go type fakeJudge struct { verdict ai.Verdict err error gate chan struct{} calls int32 } func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) { atomic.AddInt32(&f.calls, 1) if f.gate != nil { <-f.gate } return f.verdict, f.err } func waitDriftStatus(t *testing.T, c *Controller, want string) State { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { st := c.State() if st.Drift != nil && st.Drift.Status == want { return st } time.Sleep(5 * time.Millisecond) } t.Fatalf("drift status never reached %q (last: %+v)", want, c.State().Drift) return State{} } func startActive(t *testing.T, c *Controller, allowed []string) { t.Helper() if err := c.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil { t.Fatalf("start: %v", err) } } func obs(class, title string) evidence.WindowSnapshot { return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}} } func TestLocalMatchIsOnTaskWithoutJudge(t *testing.T) { c, _ := newTestController(t) fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}} c.SetDriftJudge(fj) startActive(t, c, []string{"code"}) c.RecordWindow(obs("Code", "main.go")) st := c.State() if st.Drift == nil || st.Drift.Status != "ontask" { t.Fatalf("local match should be on-task, got %+v", st.Drift) } if atomic.LoadInt32(&fj.calls) != 0 { t.Fatalf("judge must not be called on a local match") } } func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) { c, _ := newTestController(t) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "YouTube is off-task"}}) startActive(t, c, []string{"code"}) c.RecordWindow(obs("firefox", "YouTube")) st := waitDriftStatus(t, c, "drifting") if st.Drift.Reason != "YouTube is off-task" { t.Fatalf("reason not surfaced: %+v", st.Drift) } } func TestPerClassCacheAvoidsSecondJudge(t *testing.T) { c, _ := newTestController(t) fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}} c.SetDriftJudge(fj) startActive(t, c, []string{"code"}) c.RecordWindow(obs("firefox", "YouTube")) waitDriftStatus(t, c, "drifting") c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache time.Sleep(50 * time.Millisecond) if got := atomic.LoadInt32(&fj.calls); got != 1 { t.Fatalf("cached class should not re-judge, calls=%d", got) } } func TestDebounceLimitsJudgeCalls(t *testing.T) { c, _ := newTestController(t) now := time.Now() c.SetClock(func() time.Time { return now }) fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})} c.SetDriftJudge(fj) startActive(t, c, []string{"code"}) c.RecordWindow(obs("firefox", "a")) // launches one (blocked on gate) c.RecordWindow(obs("chrome", "b")) // within debounce window -> suppressed time.Sleep(50 * time.Millisecond) if got := atomic.LoadInt32(&fj.calls); got != 1 { t.Fatalf("debounce should allow one judge call, calls=%d", got) } close(fj.gate) } func TestStaleJudgmentDiscardedAfterEnd(t *testing.T) { c, _ := newTestController(t) fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})} c.SetDriftJudge(fj) startActive(t, c, []string{"code"}) c.RecordWindow(obs("firefox", "YouTube")) // launches, blocks on gate waitDriftStatus(t, c, "pending") _ = c.Complete() _ = c.End() close(fj.gate) // release stale goroutine; must be discarded time.Sleep(50 * time.Millisecond) if c.State().RuntimeState != domain.RuntimeLocked { t.Fatalf("should be Locked") } } func TestNilJudgeLeavesUnmatchedIdle(t *testing.T) { c, _ := newTestController(t) startActive(t, c, []string{"code"}) c.RecordWindow(obs("firefox", "YouTube")) st := c.State() if st.Drift == nil || st.Drift.Status != "idle" { t.Fatalf("nil judge should leave unmatched idle, got %+v", st.Drift) } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `go test ./internal/session/ -run 'TestLocalMatch|TestUnmatched|TestPerClassCache|TestDebounce|TestStaleJudgment|TestNilJudge' -v` Expected: FAIL — drift status stays idle / `pending` never reached (pipeline not wired). - [ ] **Step 3: Wire the pipeline into `RecordWindow`** Replace the body of `RecordWindow` with: ```go func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) { c.mu.Lock() c.latestWindow = snap if c.runtimeState != domain.RuntimeActive || c.stats == nil { c.mu.Unlock() c.notify() return } now := c.clock() _ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap)) c.applyEvent(now, snap) launch := c.evaluateDriftLocked(now, snap) c.mu.Unlock() if launch != nil { launch() } c.notify() } ``` Add the pipeline helpers: ```go // evaluateDriftLocked runs the local-first drift pipeline for one observation // and updates synchronous drift state. When an async judgment is warranted it // returns a closure that launches it; the caller invokes the closure after // releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has // already verified the runtime is Active. func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() { class, title := snap.Class, snap.Title // 1. Local match: authoritative on-task, no LLM. ac := domain.AllowedContext{WindowClasses: c.allowedClasses} if evidence.MatchesAllowed(ac, class, title) { c.driftStatus = driftOnTask c.driftReason = "" return nil } // 2. Per-class cache. if v, ok := c.judgedClasses[class]; ok { c.applyVerdictLocked(v) return nil } // 3. No judge wired: never block; leave idle. if c.judge == nil { c.driftStatus = driftIdle c.driftReason = "" return nil } // 4. Debounce: at most one judgment per driftDebounce window. if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce { return nil } prevStatus, prevReason := c.driftStatus, c.driftReason c.driftGen++ gen := c.driftGen c.lastJudgedAt = now c.driftStatus = driftPending c.driftReason = "" judge := c.judge commitment := "" if c.commitment != nil { commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition } return func() { ctx, cancel := context.WithTimeout(context.Background(), driftTimeout) defer cancel() v, err := judge.JudgeDrift(ctx, commitment, class, title) c.mu.Lock() if gen != c.driftGen || c.runtimeState != domain.RuntimeActive { c.mu.Unlock() return // stale: a newer judgment started or the session ended } if err != nil { // Degrade: never fabricate drift. Revert the optimistic pending. if c.driftStatus == driftPending { c.driftStatus = prevStatus c.driftReason = prevReason } c.mu.Unlock() log.Printf("session: drift judge failed: %v", err) c.notify() return } c.judgedClasses[class] = v if c.stats != nil && c.stats.Current.Class == class { c.applyVerdictLocked(v) } c.mu.Unlock() c.notify() } } // applyVerdictLocked maps a verdict onto drift state. Caller holds mu. func (c *Controller) applyVerdictLocked(v ai.Verdict) { if v.OnTask { c.driftStatus = driftOnTask c.driftReason = "" return } c.driftStatus = driftDrifting c.driftReason = v.Reason } ``` Note: `c.judgedClasses` is always non-nil while Active because `resetDriftLocked` (called by `StartManualCommitment`) initializes it. - [ ] **Step 4: Run the tests to verify they pass** Run: `go test ./internal/session/ -race -v` Expected: PASS, no data races. - [ ] **Step 5: Commit** ```bash git add internal/session/session.go internal/session/session_test.go git commit -m "Wire debounced cached async drift judgment into RecordWindow Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 6: `web` layer — commitment classes and override routes **Files:** - Modify: `internal/web/web.go` - Test: `internal/web/web_test.go` `ErrNotActive` is not an `IllegalTransitionError`, so the existing `respond` helper maps it to HTTP 400 — exactly what we want. - [ ] **Step 1: Write the failing tests** `internal/web/web_test.go` already exists and provides `newTestServer(t)` and a `post(t, r, path, body)` helper that returns an `*httptest.ResponseRecorder`. Reuse them — do NOT redefine them. Append these tests: ```go func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) { s := newTestServer(t) r := s.Router() if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest { t.Fatalf("refocus outside Active: want 400, got %d", w.Code) } if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest { t.Fatalf("ontask outside Active: want 400, got %d", w.Code) } } func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) { s := newTestServer(t) r := s.Router() _ = post(t, r, "/planning", "") body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}` if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String()) } if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { t.Fatalf("commitment did not carry allowed classes: %#v", got) } // Now Active: overrides succeed. if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK { t.Fatalf("ontask while Active: want 200, got %d", w.Code) } if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK { t.Fatalf("refocus while Active: want 200, got %d", w.Code) } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `go test ./internal/web/ -run 'TestRefocus|TestCommitmentCarries' -v` Expected: FAIL — routes 404, and `allowed_window_classes` is dropped. - [ ] **Step 3: Add the request field, wire it, add routes** In `internal/web/web.go`, extend `commitmentRequest`: ```go type commitmentRequest struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxSecs int64 `json:"timebox_secs"` AllowedWindowClasses []string `json:"allowed_window_classes"` } ``` In `handleCommitment`, pass the classes (replacing the `nil` from Task 4): ```go err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses) ``` Register routes in `Router()` (after `/complete`): ```go r.POST("/refocus", s.handleRefocus) r.POST("/ontask", s.handleOnTask) ``` Add the handlers: ```go func (s *Server) handleRefocus(c *gin.Context) { s.respond(c, s.ctrl.Refocus()) } func (s *Server) handleOnTask(c *gin.Context) { s.respond(c, s.ctrl.OnTask()) } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `go test ./internal/web/ -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/web/web.go internal/web/web_test.go git commit -m "Add /refocus and /ontask routes and carry allowed classes Co-Authored-By: Claude Opus 4.8 " ``` --- ## Task 7: UI — allowed-apps field and active-view drift interrupt **Files:** - Modify: `internal/web/static/index.html` No Go tests here; verification is `go build ./...` (the file is embedded) plus the manual smoke at the end. Add an active-view fast-path mirroring the planning one so SSE ticks update the drift region without tearing down the countdown timer. The interrupt is non-modal — it cannot lock the user out (enforcement is M8). - [ ] **Step 1: Add drift CSS** In the `