Orchestrate semantic nudge on the on-task path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:00:32 -04:00
parent b96319d847
commit b43ba5179d
3 changed files with 321 additions and 13 deletions
+107 -8
View File
@@ -40,8 +40,12 @@ const (
const (
driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second
nudgeDebounce = 5 * time.Minute
nudgeTimeout = 30 * time.Second
)
const recentTitlesMax = 10
const (
driftIdle = "idle"
driftPending = "pending"
@@ -93,8 +97,14 @@ type Controller struct {
driftStatus string
driftReason string
driftGen int
sessionGen int // bumped only at session boundaries; nudge staleness guard
lastJudgedAt time.Time
judgedClasses map[string]ai.Verdict
nudge ai.Nudger
recentTitles []string // in-memory ring of recent distinct titles this session
nudgeMessage string // current soft advisory ("" = none)
lastNudgedAt time.Time
}
// CommitmentView is the UI projection of the active commitment.
@@ -114,10 +124,13 @@ type ProposalView struct {
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
}
// DriftView is the active-only drift projection.
// DriftView is the active-only drift projection. Nudge is a separate axis from
// Status: it is populated precisely when Status is "ontask" (semantic drift
// inside an allowed app), so it cannot be folded into the status enum.
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Nudge string `json:"nudge,omitempty"`
}
type CoachView struct {
@@ -272,7 +285,7 @@ func (c *Controller) stateLocked() State {
if status == "" {
status = driftIdle
}
st.Drift = &DriftView{Status: status, Reason: c.driftReason}
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
}
return st
}
@@ -347,13 +360,41 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
c.mu.Unlock()
}
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected.
func (c *Controller) SetNudge(n ai.Nudger) {
c.mu.Lock()
c.nudge = n
c.mu.Unlock()
}
// commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu.
func (c *Controller) commitmentLineLocked() string {
if c.commitment == nil {
return ""
}
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Controller) recentTitlesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.recentTitles...)
}
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Controller) resetDriftLocked() {
c.driftStatus = driftIdle
c.driftReason = ""
c.driftGen++
c.sessionGen++
c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{}
c.recentTitles = nil
c.nudgeMessage = ""
c.lastNudgedAt = time.Time{}
}
// OnTask appends the current window class to the session allowed-context, clears
@@ -594,6 +635,7 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
now := c.clock()
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
c.applyEvent(now, snap)
c.recordTitleLocked(snap.Title)
launch := c.evaluateDriftLocked(now, snap)
c.mu.Unlock()
if launch != nil {
@@ -610,12 +652,14 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM.
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
// where the semantic nudge runs — the drift judge stays silent here, so the
// two are mutually exclusive per observation.
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if evidence.MatchesAllowed(ac, class, title) {
c.driftStatus = driftOnTask
c.driftReason = ""
return nil
return c.maybeNudgeLocked(now)
}
// 2. Per-class cache.
@@ -643,10 +687,7 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.driftStatus = driftPending
c.driftReason = ""
judge := c.judge
commitment := ""
if c.commitment != nil {
commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}
commitment := c.commitmentLineLocked()
return func() {
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
@@ -678,6 +719,64 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
}
}
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
// local-match path. It returns the nudging closure when eligible (judge wired,
// at least two titles of history, debounce elapsed), else nil. The closure
// follows the drift-judge discipline: it runs in a goroutine after the caller
// releases the mutex, re-acquires the lock, guards on sessionGen (a session-only
// counter, so an interleaved drift judgment within the same session does not
// discard the nudge), never fabricates a concern on error, and notifies only
// after unlocking. Caller holds mu and has already verified the runtime is Active.
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
if c.nudge == nil || len(c.recentTitles) < 2 {
return nil
}
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
return nil
}
c.lastNudgedAt = now
gen := c.sessionGen
nudge := c.nudge
commitment := c.commitmentLineLocked()
titles := append([]string(nil), c.recentTitles...)
return func() {
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
defer cancel()
msg, err := nudge.Nudge(ctx, commitment, titles)
c.mu.Lock()
if gen != c.sessionGen || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: a new session started or the session ended
}
if err != nil {
c.mu.Unlock()
log.Printf("session: nudge failed: %v", err)
return // never fabricate a concern; leave any prior message intact
}
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
c.mu.Unlock()
c.notify()
}
}
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Controller) recordTitleLocked(title string) {
t := strings.TrimSpace(title)
if t == "" {
return
}
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
return
}
c.recentTitles = append(c.recentTitles, t)
if len(c.recentTitles) > recentTitlesMax {
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
}
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
if v.OnTask {