Files
antidrift/docs/superpowers/plans/2026-05-31-m3.5-semantic-nudge.md
T
2026-05-31 17:52:31 -04:00

31 KiB
Raw Blame History

M3.5 — Semantic Nudge 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: Add a soft, ambient AI nudge that catches semantic drift within an allowed app ("right app, wrong work") — the case the M3 drift interceptor is structurally blind to.

Architecture: A third advisor role, ai.Nudger, on the existing ai.Service, taking primitives only (leaf package preserved). session.Controller keeps a ring of recent window titles and, only on the local-match on-task path (where the drift judge stays silent), runs a debounced async nudge using the exact M3 discipline: generation-guarded, goroutine launched after the mutex is released, never fabricates a concern on error. The advisory surfaces over SSE as a new DriftView.Nudge field and renders as a dismiss-only "Heads up" tier on the active-view banner.

Tech Stack: Go 1.26, Gin, SSE, stdlib testing only (no testify). go test -race ./... and go vet ./... must stay clean.

Spec: docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md

Model selection: Task 1 (isolated parser) and Tasks 36 (mechanical/trivial) → fast model. Task 2 (controller concurrency) → most capable model.


File Structure

File Responsibility
internal/ai/nudge.go new: Nudger interface, Service.Nudge, buildNudgePrompt, parseNudge, ErrInvalidNudge
internal/ai/nudge_test.go new: parseNudge table tests
internal/session/session.go nudge fields + constants, recentTitles ring, maybeNudgeLocked + closure, SetNudge, commitmentLineLocked, DriftView.Nudge, stateLocked wiring, resetDriftLocked additions, recentTitlesForTest
internal/session/session_test.go fakeNudger/nudgerFunc + nudge tests
internal/web/web_test.go assert nudge field in serialized state
internal/web/static/index.html "Heads up" soft tier in updateActiveDrift + CSS + client dismiss
cmd/antidriftd/main.go ctrl.SetNudge(svc) + log line
README.md M3.5 paragraph in Status

internal/web/web.go needs no change: DriftView lives in session and serializes via its JSON tags automatically.


Task 1: ai.Nudger role and response parsing

Files:

  • Create: internal/ai/nudge.go
  • Test: internal/ai/nudge_test.go

This task is self-contained in the ai leaf package. The ai.Service and its Backend already exist (internal/ai/coach.go): Service wraps a Backend and Coach/JudgeDrift are methods on it. extractJSON, ErrEmptyResponse, and ErrNoJSON are shared helpers defined in internal/ai/proposal.go. You are adding a third role, Nudge, that mirrors JudgeDrift (internal/ai/coach.go:47-75) and parseVerdict (internal/ai/verdict.go).

  • Step 1: Write the failing test

Create internal/ai/nudge_test.go:

package ai

import (
	"errors"
	"testing"
)

func TestParseNudge(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		want    string
		wantErr error
	}{
		{"on track yields empty", `{"on_track": true, "message": ""}`, "", nil},
		{"concern returns message", `{"on_track": false, "message": "editing unrelated CSS, not the auth flow"}`, "editing unrelated CSS, not the auth flow", nil},
		{"concern is trimmed", `{"on_track": false, "message": "  drifted  "}`, "drifted", nil},
		{"concern without message degrades to silence", `{"on_track": false, "message": ""}`, "", nil},
		{"json embedded in prose", `sure thing: {"on_track": false, "message": "wrong project"} done`, "wrong project", nil},
		{"empty response", "   ", "", ErrEmptyResponse},
		{"no json", "no braces here", "", ErrNoJSON},
		{"malformed json", `{"on_track": false, "message":`, "", ErrInvalidNudge},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := parseNudge(tt.in)
			if tt.wantErr != nil {
				if !errors.Is(err, tt.wantErr) {
					t.Fatalf("err = %v, want %v", err, tt.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected err: %v", err)
			}
			if got != tt.want {
				t.Fatalf("got %q, want %q", got, tt.want)
			}
		})
	}
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/ai/ -run TestParseNudge Expected: build failure — undefined: parseNudge and undefined: ErrInvalidNudge.

  • Step 3: Write the implementation

Create internal/ai/nudge.go:

package ai

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
)

// Nudger judges whether recent activity within an allowed app still serves the
// commitment. Like Coach and DriftJudge it takes primitives, not domain/evidence
// types, so ai stays a leaf package. The returned string is a one-sentence
// advisory, or "" when the trajectory is still on-task.
type Nudger interface {
	Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error)
}

// ErrInvalidNudge marks a parseable-but-unusable nudge response.
var ErrInvalidNudge = errors.New("ai: invalid nudge")

// Nudge makes Service satisfy Nudger over the same backend as Coach and JudgeDrift.
func (s *Service) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
	out, err := s.backend.Run(ctx, buildNudgePrompt(commitment, recentTitles))
	if err != nil {
		return "", err
	}
	return parseNudge(out)
}

func buildNudgePrompt(commitment string, recentTitles []string) string {
	return `You are a focus monitor. The user committed to a task and is working in an allowed application, so the application itself is fine. Judge whether the SEQUENCE of recent window titles shows them still working toward the commitment, or whether they have drifted onto unrelated work within that app.

Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"on_track": <true or false>, "message": "<short explanation, one sentence>"}

Rules:
- on_track: true if the recent titles plausibly serve the commitment, false if they show unrelated work.
- message: one short sentence naming the drift. REQUIRED when on_track is false.

Commitment: ` + commitment + `
Recent window titles (oldest to newest):
` + strings.Join(recentTitles, "\n")
}

// parseNudge extracts the advisory from raw CLI output. It reuses extractJSON
// and the shared empty/no-JSON sentinels. An on-track result yields "". A
// concern with no message degrades to "" (silence) rather than an error: the
// nudge is ambient, so the safe degenerate is to say nothing, unlike the
// interruptive drift verdict which rejects a reasonless drift.
func parseNudge(s string) (string, error) {
	if strings.TrimSpace(s) == "" {
		return "", ErrEmptyResponse
	}
	if strings.IndexByte(s, '{') < 0 {
		return "", ErrNoJSON
	}
	jsonStr, err := extractJSON(s)
	if err != nil {
		return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
	}
	var raw struct {
		OnTrack bool   `json:"on_track"`
		Message string `json:"message"`
	}
	if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
		return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
	}
	if raw.OnTrack {
		return "", nil
	}
	return strings.TrimSpace(raw.Message), nil
}
  • Step 4: Run the test to verify it passes

Run: go test ./internal/ai/ -run TestParseNudge -v Expected: PASS (all subtests).

  • Step 5: Vet and full ai-package test

Run: go vet ./internal/ai/ && go test ./internal/ai/ Expected: no vet output; PASS.

  • Step 6: Commit
git add internal/ai/nudge.go internal/ai/nudge_test.go
git commit -m "Add ai.Nudger role for semantic drift within allowed apps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: Controller nudge state, ring, and orchestration

Files:

  • Modify: internal/session/session.go
  • Test: internal/session/session_test.go

This is the concurrency-sensitive core. Study the existing drift orchestration first: the constants block (internal/session/session.go:40-43), the Controller struct fields (:71-98), DriftView (:117-121), stateLocked's drift block (:270-276), SetDriftJudge (:342-348), resetDriftLocked (:350-357), RecordWindow (:583-603), and evaluateDriftLocked (:605-679). You will mirror that drift discipline exactly: a closure is built under the lock and returned to RecordWindow, which runs it via go launch() after unlocking; the closure re-acquires the lock, checks the generation guard, and calls notify() only after unlocking.

Key design facts:

  • The drift judge and the nudge are mutually exclusive per observation: the drift judge runs only when the window is unmatched; the nudge runs only when the window is a local match (on-task). So RecordWindow's single launch func() return is sufficient — no second goroutine handle is needed.

  • The nudge reuses the existing driftGen counter as its session-identity guard (it is incremented only at session boundaries in resetDriftLocked), so a stale nudge is discarded exactly like a stale verdict.

  • Nudge state (recentTitles, nudgeMessage, lastNudgedAt) is in-memory only and cleared in resetDriftLocked.

  • Step 1: Write the failing tests

Add to the imports of internal/session/session_test.go (it currently imports context, errors, path/filepath, sync/atomic, testing, time, and the internal packages): add "fmt".

Append these test helpers and tests to internal/session/session_test.go:

// fakeNudger is a controllable ai.Nudger for tests. An optional gate channel
// blocks the call until closed, mirroring fakeJudge.
type fakeNudger struct {
	msg   string
	err   error
	gate  chan struct{}
	calls int32
}

func (f *fakeNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
	atomic.AddInt32(&f.calls, 1)
	if f.gate != nil {
		<-f.gate
	}
	return f.msg, f.err
}

// nudgerFunc adapts a function to ai.Nudger for tests needing per-call results.
type nudgerFunc func(context.Context, string, []string) (string, error)

func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, error) {
	return f(ctx, c, t)
}

func waitNudge(t *testing.T, c *Controller, want string) {
	t.Helper()
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		st := c.State()
		if st.Drift != nil && st.Drift.Nudge == want {
			return
		}
		time.Sleep(5 * time.Millisecond)
	}
	t.Fatalf("nudge never reached %q (last: %+v)", want, c.State().Drift)
}

func TestNudgeFiresOnOnTaskPath(t *testing.T) {
	c, _ := newTestController(t)
	fn := &fakeNudger{msg: "drifted to an unrelated file"}
	c.SetNudge(fn)
	startActive(t, c, []string{"code"})
	c.RecordWindow(obs("code", "auth.go"))    // history 1 -> not yet eligible
	c.RecordWindow(obs("code", "styles.css")) // history 2 -> eligible, launches
	waitNudge(t, c, "drifted to an unrelated file")
	if got := atomic.LoadInt32(&fn.calls); got != 1 {
		t.Fatalf("expected exactly one nudge call, got %d", got)
	}
}

func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
	c, _ := newTestController(t)
	fn := &fakeNudger{msg: "should not run"}
	c.SetNudge(fn)
	c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
	startActive(t, c, []string{"code"})
	c.RecordWindow(obs("firefox", "YouTube"))
	c.RecordWindow(obs("firefox", "Reddit"))
	waitDriftStatus(t, c, "drifting")
	time.Sleep(50 * time.Millisecond)
	if got := atomic.LoadInt32(&fn.calls); got != 0 {
		t.Fatalf("nudge must not run on the drift path, calls=%d", got)
	}
}

func TestNudgeDebounced(t *testing.T) {
	c, _ := newTestController(t)
	now := time.Now()
	c.SetClock(func() time.Time { return now })
	fn := &fakeNudger{msg: "drift"}
	c.SetNudge(fn)
	startActive(t, c, []string{"code"})
	c.RecordWindow(obs("code", "a.go"))
	c.RecordWindow(obs("code", "b.go")) // fires nudge #1
	waitNudge(t, c, "drift")
	c.RecordWindow(obs("code", "c.go")) // within debounce window -> suppressed
	time.Sleep(50 * time.Millisecond)
	if got := atomic.LoadInt32(&fn.calls); got != 1 {
		t.Fatalf("debounce should allow one nudge, calls=%d", got)
	}
	now = now.Add(2 * nudgeDebounce)
	c.RecordWindow(obs("code", "d.go")) // debounce elapsed -> nudge #2
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) < 2 {
		time.Sleep(5 * time.Millisecond)
	}
	if got := atomic.LoadInt32(&fn.calls); got != 2 {
		t.Fatalf("expected second nudge after debounce, calls=%d", got)
	}
}

func TestNudgeErrorDoesNotFabricate(t *testing.T) {
	c, _ := newTestController(t)
	fn := &fakeNudger{err: errors.New("backend down")}
	c.SetNudge(fn)
	startActive(t, c, []string{"code"})
	c.RecordWindow(obs("code", "a.go"))
	c.RecordWindow(obs("code", "b.go"))
	deadline := time.Now().Add(1 * time.Second)
	for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
		time.Sleep(5 * time.Millisecond)
	}
	time.Sleep(50 * time.Millisecond)
	if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
		t.Fatalf("error must not set a nudge message: %+v", st.Drift)
	}
}

func TestNudgeClearsWhenBackOnTrack(t *testing.T) {
	c, _ := newTestController(t)
	now := time.Now()
	c.SetClock(func() time.Time { return now })
	msgs := make(chan string, 2)
	msgs <- "drifted"
	msgs <- "" // back on track
	c.SetNudge(nudgerFunc(func(context.Context, string, []string) (string, error) {
		return <-msgs, nil
	}))
	startActive(t, c, []string{"code"})
	c.RecordWindow(obs("code", "a.go"))
	c.RecordWindow(obs("code", "b.go"))
	waitNudge(t, c, "drifted")
	now = now.Add(2 * nudgeDebounce)
	c.RecordWindow(obs("code", "c.go"))
	waitNudge(t, c, "") // an on-track result clears the prior advisory
}

func TestNilNudgerStaysSilentOnTask(t *testing.T) {
	c, _ := newTestController(t)
	startActive(t, c, []string{"code"})
	c.RecordWindow(obs("code", "a.go"))
	c.RecordWindow(obs("code", "b.go"))
	time.Sleep(50 * time.Millisecond)
	st := c.State()
	if st.Drift == nil || st.Drift.Status != "ontask" {
		t.Fatalf("expected ontask, got %+v", st.Drift)
	}
	if st.Drift.Nudge != "" {
		t.Fatalf("nil nudger must stay silent, got %q", st.Drift.Nudge)
	}
}

func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
	c, _ := newTestController(t)
	fn := &fakeNudger{msg: "late", gate: make(chan struct{})}
	c.SetNudge(fn)
	startActive(t, c, []string{"code"})
	c.RecordWindow(obs("code", "a.go"))
	c.RecordWindow(obs("code", "b.go")) // launches nudge, blocks on gate
	deadline := time.Now().Add(1 * time.Second)
	for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
		time.Sleep(5 * time.Millisecond)
	}
	_ = c.Complete()
	_ = c.End()
	close(fn.gate) // release stale goroutine; result must be discarded
	time.Sleep(50 * time.Millisecond)
	if c.State().RuntimeState != domain.RuntimeLocked {
		t.Fatalf("should be Locked")
	}
}

func TestRecentTitlesRingCapsAtTen(t *testing.T) {
	c, _ := newTestController(t)
	startActive(t, c, []string{"code"})
	for i := 0; i < 15; i++ {
		c.RecordWindow(obs("code", fmt.Sprintf("file%d.go", i)))
	}
	titles := c.recentTitlesForTest()
	if len(titles) != 10 {
		t.Fatalf("ring should cap at 10, got %d", len(titles))
	}
	if titles[0] != "file5.go" {
		t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
	}
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles' Expected: build failure — c.SetNudge, c.recentTitlesForTest, and nudgeDebounce are undefined.

  • Step 3: Add constants and struct fields

In internal/session/session.go, extend the drift constants block (currently internal/session/session.go:40-43) and add a ring-size constant:

const (
	driftDebounce = 10 * time.Second
	driftTimeout  = 30 * time.Second
	nudgeDebounce = 5 * time.Minute
	nudgeTimeout  = 30 * time.Second
)

const recentTitlesMax = 10

Add nudge fields to the Controller struct, immediately after the judgedClasses field (internal/session/session.go:97):

	judgedClasses  map[string]ai.Verdict

	nudge        ai.Nudger
	recentTitles []string // in-memory ring of recent distinct titles this session
	nudgeMessage string   // current soft advisory ("" = none)
	lastNudgedAt time.Time
  • Step 4: Add the Nudge field to DriftView

Replace DriftView (internal/session/session.go:117-121):

// DriftView is the active-only drift projection. Nudge is a separate axis from
// Status: it is populated precisely when Status is "ontask" (semantic drift
// inside an allowed app), so it cannot be folded into the status enum.
type DriftView struct {
	Status string `json:"status"`
	Reason string `json:"reason,omitempty"`
	Nudge  string `json:"nudge,omitempty"`
}
  • Step 5: Wire the nudge message into stateLocked

Replace the drift block in stateLocked (internal/session/session.go:270-276):

	if c.runtimeState == domain.RuntimeActive {
		status := c.driftStatus
		if status == "" {
			status = driftIdle
		}
		st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
	}
  • Step 6: Add SetNudge, commitmentLineLocked, and clear nudge state in resetDriftLocked

Add SetNudge next to SetDriftJudge (after internal/session/session.go:348):

// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected.
func (c *Controller) SetNudge(n ai.Nudger) {
	c.mu.Lock()
	c.nudge = n
	c.mu.Unlock()
}

// commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu.
func (c *Controller) commitmentLineLocked() string {
	if c.commitment == nil {
		return ""
	}
	return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}

// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Controller) recentTitlesForTest() []string {
	c.mu.Lock()
	defer c.mu.Unlock()
	return append([]string(nil), c.recentTitles...)
}

Extend resetDriftLocked (internal/session/session.go:350-357) to clear nudge state too:

// 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{}
	c.recentTitles = nil
	c.nudgeMessage = ""
	c.lastNudgedAt = time.Time{}
}
  • Step 7: Record the recent-titles ring and DRY the commitment line

Add the ring helper near applyEvent in internal/session/session.go:

// recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Controller) recordTitleLocked(title string) {
	t := strings.TrimSpace(title)
	if t == "" {
		return
	}
	if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
		return
	}
	c.recentTitles = append(c.recentTitles, t)
	if len(c.recentTitles) > recentTitlesMax {
		c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
	}
}

In RecordWindow, record the title right after c.applyEvent(now, snap) (internal/session/session.go:596):

	c.applyEvent(now, snap)
	c.recordTitleLocked(snap.Title)
	launch := c.evaluateDriftLocked(now, snap)

Apply the DRY refactor in evaluateDriftLocked: replace the inline commitment build (internal/session/session.go:646-649) with the helper:

	prevStatus, prevReason := c.driftStatus, c.driftReason
	c.driftGen++
	gen := c.driftGen
	c.lastJudgedAt = now
	c.driftStatus = driftPending
	c.driftReason = ""
	judge := c.judge
	commitment := c.commitmentLineLocked()

(The closure below still references commitment, class, title, gen, prevStatus, prevReason unchanged.)

  • Step 8: Launch the nudge from the on-task local-match branch

In evaluateDriftLocked, replace step 1 (internal/session/session.go:613-619) so the on-task branch returns the nudge closure when eligible:

	// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
	// where the semantic nudge runs — the drift judge stays silent here, so the
	// two are mutually exclusive per observation.
	ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
	if evidence.MatchesAllowed(ac, class, title) {
		c.driftStatus = driftOnTask
		c.driftReason = ""
		return c.maybeNudgeLocked(now)
	}

Add maybeNudgeLocked after evaluateDriftLocked (before applyVerdictLocked):

// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
// local-match path. It returns the nudging closure when eligible (judge wired,
// at least two titles of history, debounce elapsed), else nil. The closure
// follows the drift-judge discipline: it runs in a goroutine after the caller
// releases the mutex, re-acquires the lock, guards on driftGen, never fabricates
// a concern on error, and notifies only after unlocking. Caller holds mu and has
// already verified the runtime is Active.
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
	if c.nudge == nil || len(c.recentTitles) < 2 {
		return nil
	}
	if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
		return nil
	}
	c.lastNudgedAt = now
	gen := c.driftGen
	nudge := c.nudge
	commitment := c.commitmentLineLocked()
	titles := append([]string(nil), c.recentTitles...)

	return func() {
		ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
		defer cancel()
		msg, err := nudge.Nudge(ctx, commitment, titles)

		c.mu.Lock()
		if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
			c.mu.Unlock()
			return // stale: a new session started or the session ended
		}
		if err != nil {
			c.mu.Unlock()
			log.Printf("session: nudge failed: %v", err)
			return // never fabricate a concern; leave any prior message intact
		}
		c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
		c.mu.Unlock()
		c.notify()
	}
}
  • Step 9: Run the tests to verify they pass

Run: go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles' -v Expected: PASS for all eight new tests.

  • Step 10: Run the full session suite under the race detector

Run: go test -race ./internal/session/ Expected: PASS (all existing drift tests still green; no data races).

  • Step 11: Vet

Run: go vet ./internal/session/ Expected: no output.

  • Step 12: Commit
git add internal/session/session.go internal/session/session_test.go
git commit -m "Orchestrate semantic nudge on the on-task path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: Assert the nudge surfaces in the SSE state payload

Files:

  • Test: internal/web/web_test.go

internal/web/web.go needs no change — DriftView.Nudge serializes automatically. This task adds a web-package test proving the field reaches the wire. Reuse newTestServer/post (internal/web/web_test.go:20-38) and the stubCoach pattern (:105-112). The /commitment route already accepts allowed_window_classes (added in M3). context is already imported in this test file.

  • Step 1: Write the failing test

Append to internal/web/web_test.go:

type stubNudger struct{ msg string }

func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
	return s.msg, nil
}

func TestActiveStatePayloadCarriesNudge(t *testing.T) {
	s := newTestServer(t)
	s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
	r := s.Router()
	_ = post(t, r, "/planning", "")
	body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
	if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
		t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
	}
	// Two distinct on-task titles trip the nudge (history >= 2, first call has no
	// debounce to wait on).
	s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
	s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})

	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
			break
		}
		time.Sleep(5 * time.Millisecond)
	}
	js := s.stateJSON()
	if !strings.Contains(js, `"nudge":"drifted to unrelated work"`) {
		t.Fatalf("payload missing nudge: %s", js)
	}
}
  • Step 2: Run the test to verify it fails first, then passes

Run: go test ./internal/web/ -run TestActiveStatePayloadCarriesNudge -v Expected: PASS (the implementation already exists from Task 2; this guards the serialization). If it fails to compile because stubNudger is unused elsewhere, that is fine — it is used by this test.

  • Step 3: Run the full web suite and vet

Run: go test ./internal/web/ && go vet ./internal/web/ Expected: PASS; no vet output.

  • Step 4: Commit
git add internal/web/web_test.go
git commit -m "Assert semantic nudge reaches the state payload

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: Active-view "Heads up" soft tier

Files:

  • Modify: internal/web/static/index.html

The active view already renders drift from state.drift via updateActiveDrift(drift) (internal/web/static/index.html:80-101), called both on the active fast-path (:135-137) and full render (:188). The #drift div is the first child of the active view (:177). You will add a soft, dismiss-only "Heads up" tier that shows when drift.nudge is non-empty and the user is not drifting. Match the existing un-escaped interpolation style used for drift.reason (these are short, locally generated strings).

  • Step 1: Add the nudge CSS

After the existing .drift-hint rule (internal/web/static/index.html:36), add:

  .nudge { background: #1d2630; border: 1px solid #2f4255; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px; }
  .nudge-title { font-weight: 600; color: #8fb6d9; }
  .nudge-msg { color: #c2d2e0; margin: 4px 0 8px; }
  .nudge-actions button { padding: 6px 12px; background: #2d3242; color: #cdd2e0; }
  • Step 2: Add the dismiss state and the nudge tier

Add a module-level variable next to let renderedState = null; (internal/web/static/index.html:47):

let dismissedNudge = null;    // text of the nudge the user has dismissed

Replace updateActiveDrift (internal/web/static/index.html:80-101) with:

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');
    return;
  }
  const nudge = (drift && drift.nudge) || '';
  if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
  if (nudge && nudge !== dismissedNudge) {
    el.innerHTML = `<div class="nudge">
      <div class="nudge-title">Heads up</div>
      <div class="nudge-msg">${nudge}</div>
      <div class="nudge-actions">
        <button id="dismissnudge" type="button">Dismiss</button>
      </div></div>`;
    document.getElementById('dismissnudge').onclick = () => {
      dismissedNudge = nudge;
      const e = document.getElementById('drift');
      if (e) e.innerHTML = '';
    };
    return;
  }
  if (status === 'pending') {
    el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
    return;
  }
  el.innerHTML = '';
}
  • Step 3: Verify the build still serves the page

Run: go build ./... && go vet ./... Expected: no output (the HTML is embedded; this confirms nothing else broke).

  • Step 4: Manual smoke check of the markup (optional but recommended)

Run: grep -n "Heads up" internal/web/static/index.html Expected: one match inside updateActiveDrift.

  • Step 5: Commit
git add internal/web/static/index.html
git commit -m "Add dismiss-only Heads up tier for semantic nudges

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: Wire the nudge into the daemon

Files:

  • Modify: cmd/antidriftd/main.go

The single ai.Service already satisfies Coach and DriftJudge and is wired at cmd/antidriftd/main.go:41-44. Add the third role.

  • Step 1: Add SetNudge and update the log line

Replace the AI-wiring body (cmd/antidriftd/main.go:41-44):

		svc := ai.NewService(backend)
		ctrl.SetCoach(svc)
		ctrl.SetDriftJudge(svc)
		ctrl.SetNudge(svc)
		log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
  • Step 2: Build and vet

Run: go build ./cmd/antidriftd && go vet ./cmd/antidriftd Expected: no output.

  • Step 3: Commit
git add cmd/antidriftd/main.go
git commit -m "Wire the semantic nudge into the daemon

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: Document M3.5 in the README

Files:

  • Modify: README.md

The Status section currently leads with the M3 paragraph (README.md:26-32). Add an M3.5 paragraph above it so the newest milestone is first.

  • Step 1: Insert the M3.5 paragraph

Immediately after the ## Status heading (README.md:24) and its blank line, insert:

M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
only while you are on-task in an allowed app — periodically reads your recent
window titles and, if the trajectory has wandered from the commitment, shows a
soft, dismissible "Heads up" line (no interrupt, no buttons to fight). It is
debounced to roughly one check every five minutes, reuses the same CLI backend
as the coach and drift judge, and degrades gracefully — without it, everything
else still works.

  • Step 2: Verify placement

Run: grep -n "M3.5\|M3 (drift\|M2 (AI" README.md Expected: M3.5 line appears before the M3 (drift line, which appears before M2 (AI.

  • Step 3: Commit
git add README.md
git commit -m "Document M3.5 semantic nudge in README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Final Review

After all tasks, dispatch a final whole-diff code review and run the complete suite:

go build ./... && go vet ./... && go test -race ./...

Expected: clean build, no vet output, all tests pass under the race detector.

Confirm end-to-end: with a real AI backend, an Active session whose allowed list includes the focused app, sitting in that app while the window titles wander off-commitment, should — after ~5 min and ≥2 distinct titles — surface a soft "Heads up" line that the user can dismiss, with no hard interrupt and no overlap with the drift banner. Without a backend (SetNudge(nil) path), nothing changes.