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

47 KiB

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:

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:

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
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: aiDriftJudge 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:

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:

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):

// 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
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:

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:

type Proposal struct {
	NextAction           string
	SuccessCondition     string
	TimeboxSecs          int64
	AllowedWindowClasses []string
}

Extend rawProposal:

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:

	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:

{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}

with:

{"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:

- 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
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:

	AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
  • Step 2: Write the failing tests

Add to internal/session/session_test.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:

// 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:

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):

	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:

// DriftView is the active-only drift projection.
type DriftView struct {
	Status string `json:"status"`
	Reason string `json:"reason,omitempty"`
}

Add to State:

	Drift *DriftView `json:"drift,omitempty"`

Add the allowed-classes field to ProposalView:

	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:

			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:

	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:

	snap.AllowedWindowClasses = c.allowedClasses

In New, inside the if c.runtimeState == domain.RuntimeActive && s.SessionID != "" block (before/after replayStats), restore allowed classes:

		c.allowedClasses = s.AllowedWindowClasses

Add a reset helper (mirrors resetCoachLocked):

// 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):

// 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:

func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error {

Inside, after c.resetCoachLocked(), add:

	c.allowedClasses = append([]string(nil), allowedClasses...)
	c.resetDriftLocked()

In End, after clearing c.stats = nil, add:

	c.allowedClasses = nil
	c.resetDriftLocked()
  • Step 7: Add OnTask and Refocus
// 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:

	err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second)

Change to (temporary nil; real wiring in Task 6):

	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
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):

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:

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:

// 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
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:

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:

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):

	err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses)

Register routes in Router() (after /complete):

	r.POST("/refocus", s.handleRefocus)
	r.POST("/ontask", s.handleOnTask)

Add the handlers:

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
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:

  .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:

  if (rs === 'active' && renderedState === 'active') {
    updateActiveDrift(state.drift);
    return;
  }
  • Step 3: Add updateActiveDrift and render the drift container

Add the function near updatePlanningCoach:

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:

    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:

    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:

      <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:

    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:

    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
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):

	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:

	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
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:

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
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.