From fccf856054e662f472f228c96121b278810081bb Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 16:39:08 -0400 Subject: [PATCH] Add M3 drift interceptor implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-05-31-m3-drift-interceptor.md | 1476 +++++++++++++++++ 1 file changed, 1476 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-m3-drift-interceptor.md diff --git a/docs/superpowers/plans/2026-05-31-m3-drift-interceptor.md b/docs/superpowers/plans/2026-05-31-m3-drift-interceptor.md new file mode 100644 index 0000000..31a97a7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-m3-drift-interceptor.md @@ -0,0 +1,1476 @@ +# 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 `