Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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: 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:
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+buildDriftPrompttocoach.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, changeStartManualCommitment, clear onEnd
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
OnTaskandRefocus
// 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
StartManualCommitmentcallers
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
updateActiveDriftand 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),RecordWindowdebounced-cached-async pipeline (T5), web request field +/refocus+/ontask(T6), planning allowed-apps + active interrupt (T7), daemonSetDriftJudge(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 stringsidle|pending|ontask|drifting, andState.Drift *DriftVieware used identically across tasks. - Build-green discipline: the
StartManualCommitmentsignature change (T4) updates theweb.gocall site toniland existing test callers in the same commit; T6 swapsnilfor the real field. Each commit builds and tests clean. - Deliberate refinements: drift verdict is not persisted (recomputes after restart, avoiding stale interrupts);
parseVerdictrejects reasonless drift; judge failure revertspendingrather than fabricating drift.