Files
antidrift/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md
T
2026-05-31 17:44:30 -04:00

258 lines
12 KiB
Markdown

# 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": <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.
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 |