Files
antidrift/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md
T
2026-05-31 18:52:01 -04:00

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 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

// 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: 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:

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:

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