Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19654c2b57 | |||
| 622a1cd401 | |||
| 9dd0bb934e | |||
| 20a102b4a4 | |||
| 97577c003f | |||
| b43ba5179d | |||
| b96319d847 | |||
| 4e5e1f3881 | |||
| 5818d401c3 |
@@ -23,6 +23,15 @@ go test ./...
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
M3 (drift interceptor): while a commitment is Active, the daemon watches the
|
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
|
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
|
is authoritative for on-task; only unmatched windows are sent to the LLM drift
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ func main() {
|
|||||||
svc := ai.NewService(backend)
|
svc := ai.NewService(backend)
|
||||||
ctrl.SetCoach(svc)
|
ctrl.SetCoach(svc)
|
||||||
ctrl.SetDriftJudge(svc)
|
ctrl.SetDriftJudge(svc)
|
||||||
log.Printf("ai: %s backend (coach + drift judge)", backend.Name())
|
ctrl.SetNudge(svc)
|
||||||
|
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
src := evidence.NewSource()
|
src := evidence.NewSource()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# M3.5 — Semantic Nudge — Design
|
||||||
|
|
||||||
|
Date: 2026-05-31
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
M3 makes drift visible when the user switches to the *wrong application* — a
|
||||||
|
cheap local match owns the common case and the LLM judges the ambiguous ones,
|
||||||
|
producing an interruptive banner. But that machinery is structurally blind to a
|
||||||
|
second failure mode: the user sits in an **allowed application the whole time**
|
||||||
|
and still drifts — coding the wrong project in the editor, reading tangential
|
||||||
|
docs in an allowed browser. Local match is authoritative for on-task and caches
|
||||||
|
per window-class, so once a class is allowed the user is never re-judged while
|
||||||
|
in it. They can rabbit-hole for an hour and be called perfectly on-task.
|
||||||
|
|
||||||
|
M3.5 adds the third and final advisor role, **`Nudge`**, to close that gap. It
|
||||||
|
catches *"right app, wrong work"* — **semantic** drift within allowed apps —
|
||||||
|
with a soft, ambient, periodic check-in. It is the deliberate, narrow follow-on
|
||||||
|
that completes the original M3 roadmap entry ("drift interceptor + ambient
|
||||||
|
nudge").
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope (M3.5):**
|
||||||
|
|
||||||
|
- A new `Nudge` AI role behind a leaf-preserving interface (`ai.Nudger`);
|
||||||
|
`Service` implements it via the same CLI backends as the coach and drift
|
||||||
|
judge. Signature takes primitives only: `Nudge(ctx, commitment string,
|
||||||
|
recentTitles []string) (string, error)`, returning an advisory sentence, or
|
||||||
|
`""` when the trajectory is still on-task.
|
||||||
|
- A small in-memory ring of the **last 10 distinct window titles** seen during
|
||||||
|
the active session — the trajectory signal the nudge judges against.
|
||||||
|
- The nudge wired into `evaluateDriftLocked`'s **local-match on-task branch
|
||||||
|
only**: it runs precisely where the drift judge stays silent, so the two are
|
||||||
|
mutually exclusive per observation and never overlap.
|
||||||
|
- Debounced to at most one nudge per `nudgeDebounce` (5 min); run in a
|
||||||
|
background goroutine; gated to Active sessions with ≥ 2 titles of history.
|
||||||
|
- Surfaced over SSE as a new `DriftView.Nudge` field; the active view renders a
|
||||||
|
visually distinct **soft "Heads up" tier** on the existing drift banner —
|
||||||
|
dismiss-only, no action buttons.
|
||||||
|
- Graceful degradation: a nil nudger leaves the system silent; everything else
|
||||||
|
works unchanged.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
|
||||||
|
- Any enforcement: the nudge informs and never forces — same as the drift
|
||||||
|
interceptor. Enforcement remains M8.
|
||||||
|
- Persisting the nudge message (ephemeral, like the drift verdict — recomputes
|
||||||
|
after restart).
|
||||||
|
- A server-side dismiss route: dismissal is client-side, keyed on the message
|
||||||
|
text, and auto-clears when the trajectory recovers or the session changes.
|
||||||
|
- Re-running the nudge on the *cached-on-task* or *judged-on-task* paths. The
|
||||||
|
nudge is gated strictly to the local-match-authoritative on-task path. (Once
|
||||||
|
"This is on task" appends a class to the allowed list, it becomes a local
|
||||||
|
match thereafter, so the realistic "I'm in my app" case is covered.)
|
||||||
|
- Changing the hard local rules. "Outlook during a focus session = violation"
|
||||||
|
stays exactly where it is in the interceptor; the nudge is purely the soft
|
||||||
|
semantic layer above it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
M3.5 extends the ports-and-adapters shape of M1/M2/M3 without adding any new
|
||||||
|
infrastructure. The `ai` package gains a third role (`Nudger`) but stays a
|
||||||
|
**leaf package**: like `Coach` and `DriftJudge`, `Nudger` takes primitive
|
||||||
|
strings, not `domain`/`evidence` types. `session.Controller` orchestrates the
|
||||||
|
debounced async nudge with the *exact same discipline* as the M3 drift judge —
|
||||||
|
debounce, on-task-stretch epoch guard, goroutine launched after releasing the
|
||||||
|
mutex, `notify()` only with the mutex released, never fabricate on error.
|
||||||
|
|
||||||
|
### The `ai.Nudger` role
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Nudger judges whether recent activity within an allowed app still serves the
|
||||||
|
// commitment. 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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Service.Nudge` runs `buildNudgePrompt(commitment, recentTitles)` on the
|
||||||
|
backend and parses the result. The prompt asks for exactly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"on_track": <true or false>, "message": "<one short sentence>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsing mirrors `parseVerdict` and reuses the shared `extractJSON` /
|
||||||
|
`ErrEmptyResponse` / `ErrNoJSON` helpers:
|
||||||
|
|
||||||
|
- `on_track: true` → return `""` (nothing to surface).
|
||||||
|
- `on_track: false` with a non-empty `message` → return the trimmed message.
|
||||||
|
- `on_track: false` with **no** message → treat as on-track (return `""`). A
|
||||||
|
concern with no words is unusable and would only add noise, so the parser
|
||||||
|
swallows it rather than erroring. (This differs from `parseVerdict`, which
|
||||||
|
rejects a reasonless drift as `ErrInvalidVerdict` because the drift banner is
|
||||||
|
interruptive; the nudge is ambient and silent-by-default, so the safe
|
||||||
|
degenerate is silence, not an error.)
|
||||||
|
- Empty / no-JSON / malformed output → the corresponding error, surfaced to the
|
||||||
|
caller, which logs and stays silent.
|
||||||
|
|
||||||
|
### Controller state
|
||||||
|
|
||||||
|
New fields on `Controller` (all reset per session in `resetDriftLocked`):
|
||||||
|
|
||||||
|
- `nudge ai.Nudger` — the injected judge; nil disables nudging.
|
||||||
|
- `recentTitles []string` — ring of the last 10 distinct titles this session.
|
||||||
|
- `nudgeMessage string` — the current soft advisory ("" = none).
|
||||||
|
- `lastNudgedAt time.Time` — debounce timestamp.
|
||||||
|
|
||||||
|
A soft nudge advisory belongs to one continuous on-task stretch in an allowed
|
||||||
|
app, so the nudge is guarded by an on-task-stretch epoch (`nudgeEpoch`) rather
|
||||||
|
than `driftGen` (which bumps on every drift-judgment launch). `nudgeEpoch`
|
||||||
|
advances on session reset (`resetDriftLocked`) and whenever an observation is
|
||||||
|
not a local on-task match — i.e. the stretch ended. A nudge captures the epoch
|
||||||
|
at launch and applies its result only if the epoch is unchanged, so a nudge
|
||||||
|
whose stretch has since ended (a drift episode or a session change) is discarded
|
||||||
|
instead of surfacing stale. Leaving the allowed app additionally clears any
|
||||||
|
already-set advisory. The net effect: the advisory auto-clears when the
|
||||||
|
trajectory changes or recovers, and a stale "Heads up" never resurfaces after a
|
||||||
|
drift episode.
|
||||||
|
|
||||||
|
New constants alongside the drift ones:
|
||||||
|
|
||||||
|
```go
|
||||||
|
nudgeDebounce = 5 * time.Minute
|
||||||
|
nudgeTimeout = 30 * time.Second
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
`RecordWindow` is unchanged in shape: it still captures a single `launch func()`
|
||||||
|
from `evaluateDriftLocked` and runs it via `go launch()` after unlocking. The
|
||||||
|
drift judge and the nudge are **mutually exclusive** per observation —
|
||||||
|
matched → maybe-nudge; unmatched → maybe-judge — so one closure return covers
|
||||||
|
both.
|
||||||
|
|
||||||
|
1. **Recent-titles ring.** While Active, `RecordWindow` appends the observed
|
||||||
|
title to `recentTitles` when it is non-empty and differs from the most recent
|
||||||
|
entry, capping the slice at 10 (drop oldest). This happens before drift
|
||||||
|
evaluation so the latest title is in view.
|
||||||
|
2. **Nudge branch.** In `evaluateDriftLocked` step 1, when `MatchesAllowed`
|
||||||
|
returns true, the controller sets `driftOnTask` as today and then evaluates
|
||||||
|
nudge eligibility:
|
||||||
|
- `c.nudge != nil`, runtime is Active, `len(c.recentTitles) >= 2`, and
|
||||||
|
`lastNudgedAt` is zero or `now.Sub(lastNudgedAt) >= nudgeDebounce`.
|
||||||
|
- If eligible: stamp `lastNudgedAt = now`, capture `epoch := c.nudgeEpoch`,
|
||||||
|
the commitment string (same `NextAction — SuccessCondition` form as the
|
||||||
|
drift judge), and a **copy** of `recentTitles`; return the nudge closure.
|
||||||
|
- If not eligible: return `nil` (unchanged behavior).
|
||||||
|
3. **Nudge closure** (runs in the goroutine):
|
||||||
|
- Calls `nudge.Nudge(ctx, commitment, titles)` under a `nudgeTimeout`
|
||||||
|
context.
|
||||||
|
- Re-acquires the lock; if `epoch != c.nudgeEpoch || c.runtimeState !=
|
||||||
|
RuntimeActive` → stale, return.
|
||||||
|
- On error → log, leave `nudgeMessage` unchanged (no fabrication), unlock,
|
||||||
|
return. (`lastNudgedAt` stays set, so a failed call does not immediately
|
||||||
|
retry.)
|
||||||
|
- On success → set `c.nudgeMessage = msg` (which may be `""`, clearing a
|
||||||
|
prior advisory once the trajectory recovers); unlock; `notify()`.
|
||||||
|
|
||||||
|
### Surfacing
|
||||||
|
|
||||||
|
`DriftView` gains one field:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type DriftView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Nudge string `json:"nudge,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The nudge is a **separate axis** from `Status`: it is populated precisely when
|
||||||
|
`Status == "ontask"`, so it cannot be folded into the status enum.
|
||||||
|
`stateLocked` sets `Nudge: c.nudgeMessage` whenever it builds the `DriftView`.
|
||||||
|
|
||||||
|
In `index.html`, `updateActiveDrift(drift)` renders, in priority order:
|
||||||
|
|
||||||
|
- `drift.status === "drifting"` → the existing hard banner (Back to task / This
|
||||||
|
is on task / End session). Unchanged.
|
||||||
|
- else if `drift.nudge` is non-empty → a **soft "Heads up" tier**: muted
|
||||||
|
styling clearly lower-stakes than the hard banner, the message text, and a
|
||||||
|
single **Dismiss** control. No Refocus/OnTask/Complete buttons.
|
||||||
|
- else → clear the region.
|
||||||
|
|
||||||
|
**Client-side dismiss.** Dismiss hides the soft tier and remembers the dismissed
|
||||||
|
message text in a module-level variable; the renderer skips re-showing that
|
||||||
|
exact text. When the nudge message changes (a new concern) or clears and later
|
||||||
|
returns, it shows again. No server round-trip, no new route.
|
||||||
|
|
||||||
|
### Wiring
|
||||||
|
|
||||||
|
`cmd/antidriftd/main.go`: the single `ai.Service` already satisfies `Coach` and
|
||||||
|
`DriftJudge`; add `ctrl.SetNudge(svc)` and update the startup log line to note
|
||||||
|
the third role.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Nothing new is persisted. `recentTitles`, `nudgeMessage`, and `lastNudgedAt` are
|
||||||
|
all in-memory session state, cleared by `resetDriftLocked` on session start and
|
||||||
|
on the Active-restore path (the same place that already resets drift state to
|
||||||
|
avoid stale interrupts after a restart).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Backend/parse failure → logged, `nudgeMessage` untouched, system silent. The
|
||||||
|
nudge never fabricates a concern, mirroring the drift judge's "never fabricate
|
||||||
|
drift" rule.
|
||||||
|
- A reasonless concern from the model degrades to silence (see parsing).
|
||||||
|
- Stale results (the on-task stretch ended via a drift episode, or the session
|
||||||
|
ended/restarted mid-call) are discarded by the `nudgeEpoch` guard.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**`internal/ai/nudge_test.go`** (stdlib `testing`, table-driven like
|
||||||
|
`verdict_test.go`):
|
||||||
|
|
||||||
|
- `on_track: true` → `""`, nil error.
|
||||||
|
- `on_track: false` + message → trimmed message.
|
||||||
|
- `on_track: false` + empty message → `""` (tolerant degrade).
|
||||||
|
- empty output → `ErrEmptyResponse`; no-brace output → `ErrNoJSON`; malformed
|
||||||
|
JSON → wrapped parse error.
|
||||||
|
- JSON embedded in surrounding prose → extracted (exercises `extractJSON`
|
||||||
|
reuse).
|
||||||
|
|
||||||
|
**`internal/session/session_test.go`** (extend, reuse the `fakeJudge`-style
|
||||||
|
harness with a `fakeNudger`: configurable message/err, an optional gate channel,
|
||||||
|
an atomic call counter):
|
||||||
|
|
||||||
|
- Nudge fires on the local-match on-task path once history ≥ 2 and debounce has
|
||||||
|
elapsed; sets `DriftView.Nudge`.
|
||||||
|
- Nudge does **not** fire on the unmatched (drift-judge) path — verify the
|
||||||
|
nudger sees zero calls when the window is off-app.
|
||||||
|
- Debounce limits nudges to one per `nudgeDebounce` (drive with `SetClock`).
|
||||||
|
- A nudger error leaves `nudgeMessage` empty (no fabrication) and does not crash.
|
||||||
|
- An `on_track` result clears a previously set `nudgeMessage`.
|
||||||
|
- A stale nudge (session ended before the call returns) is discarded — message
|
||||||
|
not applied.
|
||||||
|
- A nil nudger leaves the on-task path silent (no nudge, no panic).
|
||||||
|
- `recentTitles` records distinct titles and caps at 10.
|
||||||
|
|
||||||
|
**`internal/web/web_test.go`**: assert the state JSON carries the `nudge` field
|
||||||
|
when set (extend an existing active-state test rather than adding a route test —
|
||||||
|
there is no new route).
|
||||||
|
|
||||||
|
All tests pass under `go test -race ./...`; `go vet ./...` clean.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/ai/nudge.go` | new: `Nudger` interface, `Service.Nudge`, `buildNudgePrompt`, `parseNudge` |
|
||||||
|
| `internal/ai/nudge_test.go` | new: parse/role tests |
|
||||||
|
| `internal/session/session.go` | nudge fields, constants, `recentTitles` ring in `RecordWindow`, nudge branch + closure in `evaluateDriftLocked`, `SetNudge`, `resetDriftLocked` additions, `DriftView.Nudge`, `stateLocked` wiring |
|
||||||
|
| `internal/session/session_test.go` | `fakeNudger` + nudge tests |
|
||||||
|
| `internal/web/web.go` | (only if a struct/tag touch is needed; likely none — `DriftView` is in `session`) |
|
||||||
|
| `internal/web/web_test.go` | assert `nudge` in state JSON |
|
||||||
|
| `internal/web/static/index.html` | soft "Heads up" tier in `updateActiveDrift` + CSS; client-side dismiss |
|
||||||
|
| `cmd/antidriftd/main.go` | `ctrl.SetNudge(svc)` + log line |
|
||||||
|
| `README.md` | M3.5 paragraph in Status |
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawNudge struct {
|
||||||
|
OnTrack bool `json:"on_track"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 rawNudge
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceNudgeSuccess(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"on_track": false, "message": "reading news, not the auth flow"}`}
|
||||||
|
advisory, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
if advisory == "" {
|
||||||
|
t.Fatal("expected non-empty advisory")
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "fix auth") {
|
||||||
|
t.Fatalf("prompt should embed the commitment, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceNudgeBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"}); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-8
@@ -40,8 +40,12 @@ const (
|
|||||||
const (
|
const (
|
||||||
driftDebounce = 10 * time.Second
|
driftDebounce = 10 * time.Second
|
||||||
driftTimeout = 30 * time.Second
|
driftTimeout = 30 * time.Second
|
||||||
|
nudgeDebounce = 5 * time.Minute
|
||||||
|
nudgeTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const recentTitlesMax = 10
|
||||||
|
|
||||||
const (
|
const (
|
||||||
driftIdle = "idle"
|
driftIdle = "idle"
|
||||||
driftPending = "pending"
|
driftPending = "pending"
|
||||||
@@ -93,8 +97,14 @@ type Controller struct {
|
|||||||
driftStatus string
|
driftStatus string
|
||||||
driftReason string
|
driftReason string
|
||||||
driftGen int
|
driftGen int
|
||||||
|
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
|
||||||
lastJudgedAt time.Time
|
lastJudgedAt time.Time
|
||||||
judgedClasses map[string]ai.Verdict
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitmentView is the UI projection of the active commitment.
|
// CommitmentView is the UI projection of the active commitment.
|
||||||
@@ -114,10 +124,13 @@ type ProposalView struct {
|
|||||||
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DriftView is the active-only drift projection.
|
// 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 {
|
type DriftView struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Nudge string `json:"nudge,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoachView struct {
|
type CoachView struct {
|
||||||
@@ -272,7 +285,7 @@ func (c *Controller) stateLocked() State {
|
|||||||
if status == "" {
|
if status == "" {
|
||||||
status = driftIdle
|
status = driftIdle
|
||||||
}
|
}
|
||||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason}
|
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -347,13 +360,41 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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...)
|
||||||
|
}
|
||||||
|
|
||||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||||
func (c *Controller) resetDriftLocked() {
|
func (c *Controller) resetDriftLocked() {
|
||||||
c.driftStatus = driftIdle
|
c.driftStatus = driftIdle
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
c.driftGen++
|
c.driftGen++
|
||||||
|
c.nudgeEpoch++
|
||||||
c.lastJudgedAt = time.Time{}
|
c.lastJudgedAt = time.Time{}
|
||||||
c.judgedClasses = map[string]ai.Verdict{}
|
c.judgedClasses = map[string]ai.Verdict{}
|
||||||
|
c.recentTitles = nil
|
||||||
|
c.nudgeMessage = ""
|
||||||
|
c.lastNudgedAt = time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnTask appends the current window class to the session allowed-context, clears
|
// OnTask appends the current window class to the session allowed-context, clears
|
||||||
@@ -594,6 +635,7 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|||||||
now := c.clock()
|
now := c.clock()
|
||||||
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
||||||
c.applyEvent(now, snap)
|
c.applyEvent(now, snap)
|
||||||
|
c.recordTitleLocked(snap.Title)
|
||||||
launch := c.evaluateDriftLocked(now, snap)
|
launch := c.evaluateDriftLocked(now, snap)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if launch != nil {
|
if launch != nil {
|
||||||
@@ -610,14 +652,23 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|||||||
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
|
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
|
||||||
class, title := snap.Class, snap.Title
|
class, title := snap.Class, snap.Title
|
||||||
|
|
||||||
// 1. Local match: authoritative on-task, no LLM.
|
// 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}
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||||
if evidence.MatchesAllowed(ac, class, title) {
|
if evidence.MatchesAllowed(ac, class, title) {
|
||||||
c.driftStatus = driftOnTask
|
c.driftStatus = driftOnTask
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
return nil
|
return c.maybeNudgeLocked(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Past this point the window is not a local on-task match: the on-task
|
||||||
|
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
|
||||||
|
// epoch so an in-flight nudge result is discarded rather than surfacing stale
|
||||||
|
// when the user returns.
|
||||||
|
c.nudgeMessage = ""
|
||||||
|
c.nudgeEpoch++
|
||||||
|
|
||||||
// 2. Per-class cache.
|
// 2. Per-class cache.
|
||||||
if v, ok := c.judgedClasses[class]; ok {
|
if v, ok := c.judgedClasses[class]; ok {
|
||||||
c.applyVerdictLocked(v)
|
c.applyVerdictLocked(v)
|
||||||
@@ -643,10 +694,7 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
|||||||
c.driftStatus = driftPending
|
c.driftStatus = driftPending
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
judge := c.judge
|
judge := c.judge
|
||||||
commitment := ""
|
commitment := c.commitmentLineLocked()
|
||||||
if c.commitment != nil {
|
|
||||||
commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
||||||
@@ -678,6 +726,65 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 nudgeEpoch (the current
|
||||||
|
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
|
||||||
|
// episode or a session change — is discarded rather than surfacing stale, 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
|
||||||
|
epoch := c.nudgeEpoch
|
||||||
|
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 epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // stale: the on-task stretch ended (drift episode) or the session changed
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
||||||
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
|
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
|
||||||
if v.OnTask {
|
if v.OnTask {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package session
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -549,3 +550,225 @@ func TestLeavingPlanningClearsCoach(t *testing.T) {
|
|||||||
t.Fatal("coach view must be gone once Active")
|
t.Fatal("coach view must be gone once Active")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 TestNudgeClearedOnLeavingAllowedApp(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetNudge(&fakeNudger{msg: "drifting within editor"})
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("code", "a.go"))
|
||||||
|
c.RecordWindow(obs("code", "b.go"))
|
||||||
|
waitNudge(t, c, "drifting within editor")
|
||||||
|
// Leaving the allowed app ends the on-task stretch and voids the advisory.
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
|
||||||
|
t.Fatalf("advisory must clear when leaving the allowed app: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNudgeInFlightDiscardedAfterDriftEpisode(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
now := time.Now()
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
fn := &fakeNudger{msg: "stale", gate: make(chan struct{})}
|
||||||
|
c.SetNudge(fn)
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("code", "a.go"))
|
||||||
|
c.RecordWindow(obs("code", "b.go")) // nudge launches, 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.RecordWindow(obs("firefox", "YouTube")) // ends the on-task stretch -> epoch++
|
||||||
|
close(fn.gate) // in-flight nudge returns now
|
||||||
|
c.RecordWindow(obs("code", "c.go")) // back on task (debounced; no new nudge)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
|
||||||
|
t.Fatalf("in-flight nudge from an ended stretch must not surface: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
// Start a fresh session; the previous session's in-flight nudge must not
|
||||||
|
// leak into it.
|
||||||
|
c.SetNudge(nil) // the new session launches no nudges of its own
|
||||||
|
startActive(t, c, []string{"code"})
|
||||||
|
c.RecordWindow(obs("code", "c.go"))
|
||||||
|
close(fn.gate) // release the stale goroutine from the first session
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
st := c.State()
|
||||||
|
if st.RuntimeState != domain.RuntimeActive {
|
||||||
|
t.Fatalf("expected Active second session, got %s", st.RuntimeState)
|
||||||
|
}
|
||||||
|
if st.Drift == nil || st.Drift.Nudge != "" {
|
||||||
|
t.Fatalf("stale nudge must not leak into the new session: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
.drift-actions button { margin: 0 8px 0 0; padding: 8px 14px; }
|
.drift-actions button { margin: 0 8px 0 0; padding: 8px 14px; }
|
||||||
.drift-actions button.secondary { background: #2d3242; color: #cdd2e0; }
|
.drift-actions button.secondary { background: #2d3242; color: #cdd2e0; }
|
||||||
.drift-hint { font-size: 12px; color: #7a8095; margin-bottom: 10px; }
|
.drift-hint { font-size: 12px; color: #7a8095; margin-bottom: 10px; }
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -45,6 +49,7 @@
|
|||||||
const view = document.getElementById('view');
|
const view = document.getElementById('view');
|
||||||
let countdownTimer = null;
|
let countdownTimer = null;
|
||||||
let renderedState = null; // which runtime_state the DOM currently shows
|
let renderedState = null; // which runtime_state the DOM currently shows
|
||||||
|
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||||
|
|
||||||
function post(path, body) {
|
function post(path, body) {
|
||||||
return fetch(path, {
|
return fetch(path, {
|
||||||
@@ -93,11 +98,29 @@ function updateActiveDrift(drift) {
|
|||||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||||
} else if (status === 'pending') {
|
return;
|
||||||
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
|
||||||
} else {
|
|
||||||
el.innerHTML = '';
|
|
||||||
}
|
}
|
||||||
|
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 = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePlanningCoach(coach) {
|
function updatePlanningCoach(coach) {
|
||||||
|
|||||||
@@ -170,6 +170,39 @@ func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s := newTestServer(t)
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
|
|||||||
Reference in New Issue
Block a user