19654c2b57
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
12 KiB
Markdown
263 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, 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
|
|
|
|
```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.
|
|
|
|
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:
|
|
|
|
```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 `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:
|
|
|
|
```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 (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 |
|