Files
antidrift/docs/superpowers/plans/2026-05-31-m3-drift-interceptor.md
T
2026-05-31 16:39:08 -04:00

1477 lines
47 KiB
Markdown

# 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 <noreply@anthropic.com>"
```
---
## 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": <true or false>, "reason": "<short explanation, one sentence>"}
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 <noreply@anthropic.com>"
```
---
## 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": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}
```
with:
```go
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
```
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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 `<style>` block, after the `.switches` rule, add:
```css
.drift { background: #2a1d22; border: 1px solid #5a2d39; border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.drift-title { font-weight: 700; color: #f0a3b1; }
.drift-reason { color: #e6c0c8; margin: 4px 0 10px; }
.drift-actions button { margin: 0 8px 0 0; padding: 8px 14px; }
.drift-actions button.secondary { background: #2d3242; color: #cdd2e0; }
.drift-hint { font-size: 12px; color: #7a8095; margin-bottom: 10px; }
```
- [ ] **Step 2: Add the active-view fast-path**
In `render(state)`, immediately after the existing planning fast-path block, add an active fast-path:
```js
if (rs === 'active' && renderedState === 'active') {
updateActiveDrift(state.drift);
return;
}
```
- [ ] **Step 3: Add `updateActiveDrift` and render the drift container**
Add the function near `updatePlanningCoach`:
```js
function updateActiveDrift(drift) {
const el = document.getElementById('drift');
if (!el) return;
const status = drift && drift.status;
if (status === 'drifting') {
el.innerHTML = `<div class="drift">
<div class="drift-title">⚠ Possible drift</div>
<div class="drift-reason">${drift.reason || ''}</div>
<div class="drift-actions">
<button id="refocus" type="button">Back to task</button>
<button id="ontask" type="button" class="secondary">This is on task</button>
<button id="enddrift" type="button" class="secondary">End session</button>
</div></div>`;
document.getElementById('refocus').onclick = () => post('/refocus');
document.getElementById('ontask').onclick = () => post('/ontask');
document.getElementById('enddrift').onclick = () => post('/complete');
} else if (status === 'pending') {
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
} else {
el.innerHTML = '';
}
}
```
In the active branch of `render`, add a drift container above the timer and call the updater after wiring the existing controls. Change the active `view.innerHTML` to include `<div id="drift"></div>` as the first child:
```js
view.innerHTML = `<span class="pill">Active</span>
<div id="drift"></div>
<div class="timer" id="t">--:--</div>
<div class="action">${c.next_action || ''}</div>
<p class="meta">Done when: ${c.success_condition || ''}</p>
${evidenceBlock(state.evidence)}
<button id="done">Complete</button>`;
```
and after `countdownTimer = setInterval(tick, 250);` add:
```js
updateActiveDrift(state.drift);
```
- [ ] **Step 4: Add the planning "allowed apps" field**
In the planning branch `view.innerHTML`, add an input after the Minutes input and before the Start button:
```js
<label>Allowed apps (comma-separated window classes)</label>
<input id="apps" placeholder="e.g. code, firefox">
```
Update the Start POST to include the parsed list:
```js
start.onclick = () => post('/commitment', {
next_action: na.value.trim(),
success_condition: sc.value.trim(),
timebox_secs: Math.round(+mins.value * 60),
allowed_window_classes: (document.getElementById('apps').value || '')
.split(',').map(s => s.trim()).filter(Boolean),
});
```
- [ ] **Step 5: Pre-fill allowed apps from the coach proposal**
In `updatePlanningCoach`, inside the `ready` branch after the existing pre-fill of `na`/`sc`/`mins` (still guarded by the `dataset.applied` stamp so it only runs once and won't clobber typed text), add:
```js
const apps = document.getElementById('apps');
if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
apps.value = coach.proposal.allowed_window_classes.join(', ');
}
```
- [ ] **Step 6: Verify build and manual smoke**
Run: `go build ./...`
Expected: clean.
Manual smoke (documented; run by the human): start the daemon, plan a session with allowed app `code`, start it, switch focus to an unrelated app, confirm the drift interrupt appears, click "This is on task" and confirm it clears and the class stops re-triggering; repeat and click "Back to task" to confirm it dismisses.
- [ ] **Step 7: Commit**
```bash
git add internal/web/static/index.html
git commit -m "Add allowed-apps field and active-view drift interrupt UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: Daemon wiring
**Files:**
- Modify: `cmd/antidriftd/main.go`
The single `Service` implements both `Coach` and `DriftJudge`; wire it into both.
- [ ] **Step 1: Wire `SetDriftJudge`**
Replace the AI-wiring block (currently lines ~38-43):
```go
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
log.Printf("ai coach disabled: %v", err)
} else {
ctrl.SetCoach(ai.NewService(backend))
log.Printf("ai coach: %s backend", backend.Name())
}
```
with:
```go
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
log.Printf("ai disabled: %v", err)
} else {
svc := ai.NewService(backend)
ctrl.SetCoach(svc)
ctrl.SetDriftJudge(svc)
log.Printf("ai: %s backend (coach + drift judge)", backend.Name())
}
```
- [ ] **Step 2: Verify build**
Run: `go build ./... && go vet ./...`
Expected: clean.
- [ ] **Step 3: Commit**
```bash
git add cmd/antidriftd/main.go
git commit -m "Wire drift judge into the daemon alongside the coach
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 9: README / roadmap note
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Update the Status section**
In `README.md`, add an M3 paragraph above the M2 paragraph in the `## Status` section:
```markdown
M3 (drift interceptor): while a commitment is Active, the daemon watches the
focused window. A cheap local match against the session's allowed window classes
is authoritative for on-task; only unmatched windows are sent to the LLM drift
judge (debounced and cached per class, run asynchronously). When it judges you
off-task, the active view shows a dismissible interrupt: "Back to task", "This is
on task" (which adds the app to the session's allowed list), or "End session".
The drift judge degrades gracefully — without it, local matching still runs.
```
- [ ] **Step 2: Full test sweep**
Run: `go test -race ./... && go vet ./...`
Expected: all PASS, no races, clean vet.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "Document M3 drift interceptor in README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Final Review
After all tasks: dispatch a final code reviewer over the entire M3 diff (`git diff main...HEAD` if on a branch, or the M3 commit range), then use superpowers:finishing-a-development-branch.
## Self-Review Notes (author)
- **Spec coverage:** evidence matching (T1), `DriftJudge`/`Verdict`/`parseVerdict`/`buildDriftPrompt`/`JudgeDrift` (T2), coach allowed-classes (T3), controller fields/projection/persistence/overrides/signature (T4), `RecordWindow` debounced-cached-async pipeline (T5), web request field + `/refocus` + `/ontask` (T6), planning allowed-apps + active interrupt (T7), daemon `SetDriftJudge` (T8), README (T9). All spec sections map to a task.
- **Type consistency:** `MatchesAllowed(domain.AllowedContext, class, title)`, `JudgeDrift(ctx, commitment, class, title)`, `Verdict{OnTask, Reason}`, `Proposal.AllowedWindowClasses`, `StartManualCommitment(..., allowedClasses []string)`, drift status strings `idle|pending|ontask|drifting`, and `State.Drift *DriftView` are used identically across tasks.
- **Build-green discipline:** the `StartManualCommitment` signature change (T4) updates the `web.go` call site to `nil` and existing test callers in the same commit; T6 swaps `nil` for the real field. Each commit builds and tests clean.
- **Deliberate refinements:** drift *verdict* is not persisted (recomputes after restart, avoiding stale interrupts); `parseVerdict` rejects reasonless drift; judge failure reverts `pending` rather than fabricating drift.