# 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": , "message": ""} ``` 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 |