Tie nudge validity to the on-task stretch

A soft nudge advisory belongs to one continuous on-task stretch in an allowed
app. Guard it with an on-task-stretch epoch (advanced on session reset and on
any non-on-task-match observation) instead of a session-only counter, and clear
the advisory on leaving the allowed app, so a stale 'Heads up' never resurfaces
after a drift episode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:20:22 -04:00
parent 9dd0bb934e
commit 622a1cd401
3 changed files with 54 additions and 27 deletions
@@ -64,7 +64,7 @@ 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
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
@@ -109,15 +109,17 @@ New fields on `Controller` (all reset per session in `resetDriftLocked`):
- `nudgeMessage string` — the current soft advisory ("" = none).
- `lastNudgedAt time.Time` — debounce timestamp.
The nudge needs a session-identity guard, but `driftGen` cannot serve it:
`driftGen` is bumped on every drift-judgment launch (not only at session
boundaries), so an interleaved drift judgment within the same session would
wrongly discard a valid in-flight nudge. The nudge therefore uses a dedicated
`sessionGen` counter, incremented only in `resetDriftLocked`, so it survives an
interleaved drift judgment yet is still discarded when the session ends. A nudge
in flight when the user clicks "This is on task" / "Back to task" likewise
remains valid — those actions do not change `sessionGen` and the nudge is about
the session trajectory, not a single class.
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: