From 5818d401c38de9a68a0292f6f28ac401874a9db7 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 17:44:30 -0400 Subject: [PATCH] Design M3.5 semantic nudge within allowed apps Co-Authored-By: Claude Opus 4.8 --- .../2026-05-31-m3.5-semantic-nudge-design.md | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md diff --git a/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md b/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md new file mode 100644 index 0000000..677455c --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md @@ -0,0 +1,257 @@ +# 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, session-generation 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. + +The session-identity guard reuses the existing `driftGen` counter: it is +incremented only at session boundaries (`resetDriftLocked`), so a single +generation value correctly discards both stale drift *and* stale nudge results. +A nudge in flight when the user clicks "This is on task" / "Back to task" +remains valid — those actions do not change `driftGen` and the nudge is about +the session trajectory, not a single class. + +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 `gen := c.driftGen`, 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 `gen != c.driftGen || 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 (session ended or restarted mid-call) are discarded by the + `driftGen` 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 |