Plan M3.5 semantic nudge implementation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
# 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 3–6 (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`:
|
||||
|
||||
```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`:
|
||||
|
||||
```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**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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:
|
||||
|
||||
```go
|
||||
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`):
|
||||
|
||||
```go
|
||||
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`):
|
||||
|
||||
```go
|
||||
// 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`):
|
||||
|
||||
```go
|
||||
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`):
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||
func (c *Controller) resetDriftLocked() {
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
c.driftGen++
|
||||
c.lastJudgedAt = time.Time{}
|
||||
c.judgedClasses = map[string]ai.Verdict{}
|
||||
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`:
|
||||
|
||||
```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`):
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
// 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`):
|
||||
|
||||
```go
|
||||
// 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**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```css
|
||||
.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`):
|
||||
|
||||
```js
|
||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||
```
|
||||
|
||||
Replace `updateActiveDrift` (`internal/web/static/index.html:80-101`) with:
|
||||
|
||||
```js
|
||||
function updateActiveDrift(drift) {
|
||||
const el = document.getElementById('drift');
|
||||
if (!el) return;
|
||||
const status = drift && drift.status;
|
||||
if (status === 'drifting') {
|
||||
el.innerHTML = `<div class="drift">
|
||||
<div class="drift-title">⚠ Possible drift</div>
|
||||
<div class="drift-reason">${drift.reason || ''}</div>
|
||||
<div class="drift-actions">
|
||||
<button id="refocus" type="button">Back to task</button>
|
||||
<button id="ontask" type="button" class="secondary">This is on task</button>
|
||||
<button id="enddrift" type="button" class="secondary">End session</button>
|
||||
</div></div>`;
|
||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||
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**
|
||||
|
||||
```bash
|
||||
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`):
|
||||
|
||||
```go
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```markdown
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
```
|
||||
Reference in New Issue
Block a user