Design M3.5 semantic nudge within allowed apps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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": <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 |
|
||||
Reference in New Issue
Block a user