Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
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
NudgeAI role behind a leaf-preserving interface (ai.Nudger);Serviceimplements 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.Nudgefield; 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
// 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:
{"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: falsewith a non-emptymessage→ return the trimmed message.on_track: falsewith 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 fromparseVerdict, which rejects a reasonless drift asErrInvalidVerdictbecause 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:
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.
- Recent-titles ring. While Active,
RecordWindowappends the observed title torecentTitleswhen 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. - Nudge branch. In
evaluateDriftLockedstep 1, whenMatchesAllowedreturns true, the controller setsdriftOnTaskas today and then evaluates nudge eligibility:c.nudge != nil, runtime is Active,len(c.recentTitles) >= 2, andlastNudgedAtis zero ornow.Sub(lastNudgedAt) >= nudgeDebounce.- If eligible: stamp
lastNudgedAt = now, captureepoch := c.nudgeEpoch, the commitment string (sameNextAction — SuccessConditionform as the drift judge), and a copy ofrecentTitles; return the nudge closure. - If not eligible: return
nil(unchanged behavior).
- Nudge closure (runs in the goroutine):
- Calls
nudge.Nudge(ctx, commitment, titles)under anudgeTimeoutcontext. - Re-acquires the lock; if
epoch != c.nudgeEpoch || c.runtimeState != RuntimeActive→ stale, return. - On error → log, leave
nudgeMessageunchanged (no fabrication), unlock, return. (lastNudgedAtstays 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().
- Calls
Surfacing
DriftView gains one field:
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.nudgeis 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,
nudgeMessageuntouched, 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
nudgeEpochguard.
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
extractJSONreuse).
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 withSetClock). - A nudger error leaves
nudgeMessageempty (no fabrication) and does not crash. - An
on_trackresult clears a previously setnudgeMessage. - 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).
recentTitlesrecords 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 |