fccf856054
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1477 lines
47 KiB
Markdown
1477 lines
47 KiB
Markdown
# M3 — Drift Interceptor Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** While a commitment is Active, watch the focused window, judge drift (cheap local match first, LLM only for ambiguous cases), and surface a dismissible active-view interrupt with refocus / "this is on task" / end actions.
|
|
|
|
**Architecture:** Extends the M1/M2 ports-and-adapters shape. `evidence` gains pure allowed-context matching (ported from `legacy/src/context.rs`). `ai` gains a second leaf-preserving role, `DriftJudge` (primitives only, no domain imports). `session.Controller` orchestrates a debounced, per-class-cached, async judgment on the `RecordWindow` hot path — same goroutine + generation-guard + notify-after-unlock discipline as M2's `RequestCoach`. The browser renders the interrupt over the existing SSE stream.
|
|
|
|
**Tech Stack:** Go 1.26, Gin, SSE, stdlib `testing` (no testify), `go test -race ./...`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Responsibility | Task |
|
|
| ---- | -------------- | ---- |
|
|
| `internal/evidence/context.go` (create) | Pure allowed-context matching helpers + `MatchesAllowed` | 1 |
|
|
| `internal/evidence/context_test.go` (create) | Port of the Rust matching test table | 1 |
|
|
| `internal/ai/verdict.go` (create) | `Verdict`, `ErrInvalidVerdict`, `parseVerdict` | 2 |
|
|
| `internal/ai/coach.go` (modify) | `DriftJudge` interface, `Service.JudgeDrift`, `buildDriftPrompt` | 2 |
|
|
| `internal/ai/verdict_test.go` (create) | `parseVerdict` + `JudgeDrift` over a fake backend | 2 |
|
|
| `internal/ai/proposal.go` (modify) | `Proposal.AllowedWindowClasses` + parse | 3 |
|
|
| `internal/ai/coach.go` (modify) | coach prompt asks for `allowed_window_classes` | 3 |
|
|
| `internal/ai/proposal_test.go` (modify) | allowed-classes present / absent / empty | 3 |
|
|
| `internal/store/store.go` (modify) | `Snapshot.AllowedWindowClasses` | 4 |
|
|
| `internal/session/session.go` (modify) | drift fields/constants, `DriftView`+`State.Drift`, `ProposalView` field, persist/restore, `StartManualCommitment` param, `SetDriftJudge`, `OnTask`, `Refocus`, drift reset | 4 |
|
|
| `internal/web/web.go` (modify) | keep build green: pass `nil` to new signature (real wiring in Task 6) | 4 |
|
|
| `internal/session/session_test.go` (modify) | update existing `StartManualCommitment` callers; `OnTask`/`Refocus` tests | 4 |
|
|
| `internal/session/session.go` (modify) | `RecordWindow` drift pipeline: `evaluateDriftLocked`, `applyVerdictLocked`, async judge | 5 |
|
|
| `internal/session/session_test.go` (modify) | fake `DriftJudge`: local match, unmatched, cache, debounce, stale gen, nil judge | 5 |
|
|
| `internal/web/web.go` (modify) | `commitmentRequest.AllowedWindowClasses`, `POST /refocus`, `POST /ontask` | 6 |
|
|
| `internal/web/web_test.go` (modify/create) | route happy paths + outside-Active 400 + commitment carries classes | 6 |
|
|
| `internal/web/static/index.html` (modify) | planning "allowed apps" field; active-view drift interrupt | 7 |
|
|
| `cmd/antidriftd/main.go` (modify) | `ctrl.SetDriftJudge(svc)` alongside `SetCoach` | 8 |
|
|
| `README.md` (modify) | M3 status note | 9 |
|
|
|
|
---
|
|
|
|
## Task 1: `evidence` local matching (ported from Rust)
|
|
|
|
**Files:**
|
|
- Create: `internal/evidence/context.go`
|
|
- Test: `internal/evidence/context_test.go`
|
|
|
|
The `evidence` package may import `domain` (a pure leaf), so there is no cycle. Match logic mirrors `legacy/src/context.rs` exactly: class is exact casefolded; title is casefolded substring; domain is exact-or-subdomain with trailing-dot/whitespace/case normalization; command is the casefolded executable basename. `MatchesAllowed` is the live entry point used by the controller; `domainAllowed`/`commandAllowed` are ported for completeness and exercised by tests but have no live data source in M3.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `internal/evidence/context_test.go`:
|
|
|
|
```go
|
|
package evidence
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"antidrift/internal/domain"
|
|
)
|
|
|
|
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
|
ctx := domain.AllowedContext{WindowClasses: []string{" code "}}
|
|
if !windowClassAllowed(ctx, " Code ") {
|
|
t.Fatal("class should match case-insensitively after trim")
|
|
}
|
|
if windowClassAllowed(ctx, "firefox") {
|
|
t.Fatal("unrelated class must not match")
|
|
}
|
|
if windowClassAllowed(ctx, " ") {
|
|
t.Fatal("empty candidate must not match")
|
|
}
|
|
}
|
|
|
|
func TestWindowTitleMatchesSubstring(t *testing.T) {
|
|
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
|
|
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
|
|
t.Fatal("title substring should match after trim/casefold")
|
|
}
|
|
if windowTitleAllowed(ctx, "random video") {
|
|
t.Fatal("unrelated title must not match")
|
|
}
|
|
}
|
|
|
|
func TestDomainMatchesExactAndSubdomain(t *testing.T) {
|
|
ctx := domain.AllowedContext{Domains: []string{" GitHub.COM. "}}
|
|
if !domainAllowed(ctx, "github.com") {
|
|
t.Fatal("exact domain should match")
|
|
}
|
|
if !domainAllowed(ctx, " DOCS.GITHUB.COM. ") {
|
|
t.Fatal("subdomain should match after normalization")
|
|
}
|
|
if domainAllowed(ctx, "evilgithub.com") {
|
|
t.Fatal("suffix-without-dot must not match")
|
|
}
|
|
}
|
|
|
|
func TestCommandMatchesExecutableBasename(t *testing.T) {
|
|
ctx := domain.AllowedContext{Commands: []string{"cargo"}}
|
|
if !commandAllowed(ctx, "/home/felixm/.cargo/bin/cargo test") {
|
|
t.Fatal("basename of full path should match")
|
|
}
|
|
if !commandAllowed(ctx, "cargo") {
|
|
t.Fatal("bare command should match")
|
|
}
|
|
if commandAllowed(ctx, "/usr/bin/git status") {
|
|
t.Fatal("unrelated command must not match")
|
|
}
|
|
}
|
|
|
|
func TestMatchesAllowedClassOrTitle(t *testing.T) {
|
|
ctx := domain.AllowedContext{
|
|
WindowClasses: []string{"code"},
|
|
WindowTitleSubstrings: []string{"antidrift"},
|
|
}
|
|
if !MatchesAllowed(ctx, "Code", "anything") {
|
|
t.Fatal("class match should satisfy MatchesAllowed")
|
|
}
|
|
if !MatchesAllowed(ctx, "firefox", "my AntiDrift tab") {
|
|
t.Fatal("title match should satisfy MatchesAllowed")
|
|
}
|
|
if MatchesAllowed(ctx, "firefox", "youtube") {
|
|
t.Fatal("neither match must be off-task")
|
|
}
|
|
if MatchesAllowed(domain.AllowedContext{}, "code", "antidrift") {
|
|
t.Fatal("empty context matches nothing")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `go test ./internal/evidence/ -run TestMatchesAllowed -v`
|
|
Expected: FAIL — `undefined: MatchesAllowed` (compile error).
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
Create `internal/evidence/context.go`:
|
|
|
|
```go
|
|
package evidence
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"antidrift/internal/domain"
|
|
)
|
|
|
|
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
|
// M3 matches on window class and title only; the domain and command helpers are
|
|
// ported for completeness and tests but have no live data source yet.
|
|
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool {
|
|
return windowClassAllowed(ctx, class) || windowTitleAllowed(ctx, title)
|
|
}
|
|
|
|
func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
|
|
candidate = normalizeCasefolded(candidate)
|
|
if candidate == "" {
|
|
return false
|
|
}
|
|
for _, allowed := range ctx.WindowClasses {
|
|
if normalizeCasefolded(allowed) == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func windowTitleAllowed(ctx domain.AllowedContext, candidate string) bool {
|
|
candidate = normalizeCasefolded(candidate)
|
|
if candidate == "" {
|
|
return false
|
|
}
|
|
for _, allowed := range ctx.WindowTitleSubstrings {
|
|
a := normalizeCasefolded(allowed)
|
|
if a != "" && strings.Contains(candidate, a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func domainAllowed(ctx domain.AllowedContext, candidate string) bool {
|
|
candidate = normalizeDomain(candidate)
|
|
if candidate == "" {
|
|
return false
|
|
}
|
|
for _, allowed := range ctx.Domains {
|
|
a := normalizeDomain(allowed)
|
|
if a == "" {
|
|
continue
|
|
}
|
|
if candidate == a {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(candidate, a) && strings.HasSuffix(candidate[:len(candidate)-len(a)], ".") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func commandAllowed(ctx domain.AllowedContext, candidate string) bool {
|
|
candidate = executableBasename(candidate)
|
|
if candidate == "" {
|
|
return false
|
|
}
|
|
for _, allowed := range ctx.Commands {
|
|
if executableBasename(allowed) == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeDomain(v string) string {
|
|
return strings.ToLower(strings.TrimRight(strings.TrimSpace(v), "."))
|
|
}
|
|
|
|
func normalizeCasefolded(v string) string {
|
|
return strings.ToLower(strings.TrimSpace(v))
|
|
}
|
|
|
|
// executableBasename returns the lowercased basename of the first whitespace-
|
|
// separated token (the executable), splitting on both / and \.
|
|
func executableBasename(command string) string {
|
|
fields := strings.Fields(command)
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
exe := fields[0]
|
|
if i := strings.LastIndexAny(exe, `/\`); i >= 0 {
|
|
exe = exe[i+1:]
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(exe))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `go test ./internal/evidence/ -v`
|
|
Expected: PASS (all matching tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/evidence/context.go internal/evidence/context_test.go
|
|
git commit -m "Port allowed-context matching to evidence package
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `ai` — `DriftJudge` role
|
|
|
|
**Files:**
|
|
- Create: `internal/ai/verdict.go`
|
|
- Modify: `internal/ai/coach.go`
|
|
- Test: `internal/ai/verdict_test.go`
|
|
|
|
`ai` stays a leaf package: `JudgeDrift` takes **primitive strings**, not `domain`/`evidence` types. `parseVerdict` reuses the existing `extractJSON` and the `ErrEmptyResponse`/`ErrNoJSON` sentinels from `proposal.go`. A drifting verdict (`on_task: false`) with no reason is unusable for the UI, so it is rejected as `ErrInvalidVerdict`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `internal/ai/verdict_test.go`:
|
|
|
|
```go
|
|
package ai
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseVerdictOnTask(t *testing.T) {
|
|
v, err := parseVerdict(`{"on_task": true, "reason": ""}`)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if !v.OnTask {
|
|
t.Fatalf("expected on-task, got %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestParseVerdictDriftingChatty(t *testing.T) {
|
|
v, err := parseVerdict("Sure! here is the call:\n{\"on_task\": false, \"reason\": \"Reddit is unrelated\"}\nhope that helps")
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if v.OnTask || v.Reason != "Reddit is unrelated" {
|
|
t.Fatalf("bad verdict: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestParseVerdictDriftingNeedsReason(t *testing.T) {
|
|
if _, err := parseVerdict(`{"on_task": false}`); !errors.Is(err, ErrInvalidVerdict) {
|
|
t.Fatalf("want ErrInvalidVerdict for reasonless drift, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseVerdictEmpty(t *testing.T) {
|
|
if _, err := parseVerdict(" "); !errors.Is(err, ErrEmptyResponse) {
|
|
t.Fatalf("want ErrEmptyResponse, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseVerdictNoJSON(t *testing.T) {
|
|
if _, err := parseVerdict("I cannot help"); !errors.Is(err, ErrNoJSON) {
|
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceJudgeDrift(t *testing.T) {
|
|
fb := &fakeBackend{out: `{"on_task": false, "reason": "YouTube is off-task"}`}
|
|
v, err := NewService(fb).JudgeDrift(context.Background(), "write the report", "firefox", "YouTube")
|
|
if err != nil {
|
|
t.Fatalf("judge: %v", err)
|
|
}
|
|
if v.OnTask || v.Reason == "" {
|
|
t.Fatalf("bad verdict: %+v", v)
|
|
}
|
|
if !strings.Contains(fb.gotPrompt, "write the report") || !strings.Contains(fb.gotPrompt, "YouTube") {
|
|
t.Fatalf("prompt should embed commitment and window: %s", fb.gotPrompt)
|
|
}
|
|
}
|
|
|
|
func TestServiceJudgeDriftBackendError(t *testing.T) {
|
|
fb := &fakeBackend{err: errors.New("boom")}
|
|
if _, err := NewService(fb).JudgeDrift(context.Background(), "x", "c", "t"); err == nil {
|
|
t.Fatal("want backend error")
|
|
}
|
|
}
|
|
```
|
|
|
|
(`fakeBackend` already exists in `internal/ai/coach_test.go`.)
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `go test ./internal/ai/ -run TestParseVerdict -v`
|
|
Expected: FAIL — `undefined: parseVerdict` / `ErrInvalidVerdict`.
|
|
|
|
- [ ] **Step 3: Write `verdict.go`**
|
|
|
|
Create `internal/ai/verdict.go`:
|
|
|
|
```go
|
|
package ai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Verdict is the drift judge's call on a single window.
|
|
type Verdict struct {
|
|
OnTask bool
|
|
Reason string
|
|
}
|
|
|
|
// ErrInvalidVerdict marks a parseable-but-unusable judge response.
|
|
var ErrInvalidVerdict = errors.New("ai: invalid verdict")
|
|
|
|
type rawVerdict struct {
|
|
OnTask bool `json:"on_task"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// parseVerdict extracts and validates a Verdict from raw CLI output. It reuses
|
|
// extractJSON and the shared empty/no-JSON sentinels. A drifting verdict with no
|
|
// reason is unusable for the UI and is rejected as ErrInvalidVerdict.
|
|
func parseVerdict(s string) (Verdict, error) {
|
|
if strings.TrimSpace(s) == "" {
|
|
return Verdict{}, ErrEmptyResponse
|
|
}
|
|
if strings.IndexByte(s, '{') < 0 {
|
|
return Verdict{}, ErrNoJSON
|
|
}
|
|
jsonStr, err := extractJSON(s)
|
|
if err != nil {
|
|
return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err)
|
|
}
|
|
var raw rawVerdict
|
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
|
return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err)
|
|
}
|
|
v := Verdict{OnTask: raw.OnTask, Reason: strings.TrimSpace(raw.Reason)}
|
|
if !v.OnTask && v.Reason == "" {
|
|
return Verdict{}, ErrInvalidVerdict
|
|
}
|
|
return v, nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add `DriftJudge` + `JudgeDrift` + `buildDriftPrompt` to `coach.go`**
|
|
|
|
Append to `internal/ai/coach.go` (the file already imports `context`):
|
|
|
|
```go
|
|
// DriftJudge decides whether the current window is on-task for a commitment.
|
|
// It takes primitives, not domain/evidence types, so ai stays a leaf package.
|
|
type DriftJudge interface {
|
|
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
|
}
|
|
|
|
// JudgeDrift makes Service satisfy DriftJudge over the same backend as Coach.
|
|
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error) {
|
|
out, err := s.backend.Run(ctx, buildDriftPrompt(commitment, windowClass, windowTitle))
|
|
if err != nil {
|
|
return Verdict{}, err
|
|
}
|
|
return parseVerdict(out)
|
|
}
|
|
|
|
func buildDriftPrompt(commitment, class, title string) string {
|
|
return `You are a focus monitor. The user committed to a task. Decide whether their CURRENT window is on-task or a distraction.
|
|
|
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
|
{"on_task": <true or false>, "reason": "<short explanation, one sentence>"}
|
|
|
|
Rules:
|
|
- on_task: true if the window plausibly serves the commitment, false if it is a distraction.
|
|
- reason: one short sentence. REQUIRED when on_task is false.
|
|
|
|
Commitment: ` + commitment + `
|
|
Current window class: ` + class + `
|
|
Current window title: ` + title
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `go test ./internal/ai/ -v`
|
|
Expected: PASS (verdict tests + existing coach/proposal/backend tests).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/ai/verdict.go internal/ai/coach.go internal/ai/verdict_test.go
|
|
git commit -m "Add DriftJudge AI role with verdict parsing
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Coach proposes allowed window classes
|
|
|
|
**Files:**
|
|
- Modify: `internal/ai/proposal.go`
|
|
- Modify: `internal/ai/coach.go`
|
|
- Test: `internal/ai/proposal_test.go`
|
|
|
|
The coach now also proposes window classes. The field is optional: absent or empty is valid (the user fills it in). Blank entries are dropped.
|
|
|
|
- [ ] **Step 1: Write/extend the failing test**
|
|
|
|
Add to `internal/ai/proposal_test.go`:
|
|
|
|
```go
|
|
func TestParseProposalReadsAllowedClasses(t *testing.T) {
|
|
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20,"allowed_window_classes":["code"," firefox ",""]}`)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(p.AllowedWindowClasses) != 2 || p.AllowedWindowClasses[0] != "code" || p.AllowedWindowClasses[1] != "firefox" {
|
|
t.Fatalf("classes wrong (blanks should drop, trim applied): %#v", p.AllowedWindowClasses)
|
|
}
|
|
}
|
|
|
|
func TestParseProposalAllowedClassesOptional(t *testing.T) {
|
|
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20}`)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(p.AllowedWindowClasses) != 0 {
|
|
t.Fatalf("absent field should yield empty slice, got %#v", p.AllowedWindowClasses)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `go test ./internal/ai/ -run TestParseProposalReadsAllowedClasses -v`
|
|
Expected: FAIL — `p.AllowedWindowClasses undefined`.
|
|
|
|
- [ ] **Step 3: Add the field and parse it in `proposal.go`**
|
|
|
|
In `internal/ai/proposal.go`, extend `Proposal`:
|
|
|
|
```go
|
|
type Proposal struct {
|
|
NextAction string
|
|
SuccessCondition string
|
|
TimeboxSecs int64
|
|
AllowedWindowClasses []string
|
|
}
|
|
```
|
|
|
|
Extend `rawProposal`:
|
|
|
|
```go
|
|
type rawProposal struct {
|
|
NextAction string `json:"next_action"`
|
|
SuccessCondition string `json:"success_condition"`
|
|
TimeboxMinutes int64 `json:"timebox_minutes"`
|
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
|
}
|
|
```
|
|
|
|
Replace the final `return` of `parseProposal` (the success path) with:
|
|
|
|
```go
|
|
classes := make([]string, 0, len(raw.AllowedWindowClasses))
|
|
for _, cls := range raw.AllowedWindowClasses {
|
|
if t := strings.TrimSpace(cls); t != "" {
|
|
classes = append(classes, t)
|
|
}
|
|
}
|
|
return Proposal{
|
|
NextAction: na,
|
|
SuccessCondition: sc,
|
|
TimeboxSecs: raw.TimeboxMinutes * 60,
|
|
AllowedWindowClasses: classes,
|
|
}, nil
|
|
```
|
|
|
|
- [ ] **Step 4: Update the coach prompt in `coach.go`**
|
|
|
|
In `buildPrompt`, change the JSON shape line and add a rule. Replace:
|
|
|
|
```go
|
|
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}
|
|
```
|
|
|
|
with:
|
|
|
|
```go
|
|
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
|
|
```
|
|
|
|
and add a rule line after the `timebox_minutes` rule:
|
|
|
|
```go
|
|
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `go test ./internal/ai/ -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/ai/proposal.go internal/ai/coach.go internal/ai/proposal_test.go
|
|
git commit -m "Extend coach proposal with allowed window classes
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Controller drift state, persistence, overrides
|
|
|
|
**Files:**
|
|
- Modify: `internal/store/store.go`
|
|
- Modify: `internal/session/session.go`
|
|
- Modify: `internal/web/web.go` (keep build green only)
|
|
- Test: `internal/session/session_test.go`
|
|
|
|
This task adds all drift fields, the `DriftView`/`State.Drift` projection, the `ProposalView` allowed-classes field, allowed-context persistence/restore, the new `StartManualCommitment` parameter, `SetDriftJudge`, and the `OnTask`/`Refocus` overrides. The `RecordWindow` pipeline that *launches* judgments comes in Task 5. To keep `go build ./...` green after the signature change, update the `web.go` call site to pass `nil` (real wiring lands in Task 6) and the existing test callers likewise.
|
|
|
|
- [ ] **Step 1: Add the snapshot field**
|
|
|
|
In `internal/store/store.go`, add to `Snapshot`:
|
|
|
|
```go
|
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing tests**
|
|
|
|
Add to `internal/session/session_test.go`:
|
|
|
|
```go
|
|
func TestOnTaskRequiresActive(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
if err := c.OnTask(); !errors.Is(err, ErrNotActive) {
|
|
t.Fatalf("OnTask outside Active should error, got %v", err)
|
|
}
|
|
if err := c.Refocus(); !errors.Is(err, ErrNotActive) {
|
|
t.Fatalf("Refocus outside Active should error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
first, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
_ = first.EnterPlanning()
|
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
second, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
|
t.Fatalf("allowed classes not restored: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
|
|
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
|
if err := c.OnTask(); err != nil {
|
|
t.Fatalf("ontask: %v", err)
|
|
}
|
|
got := c.AllowedClassesForTest()
|
|
if len(got) != 1 || got[0] != "slack" {
|
|
t.Fatalf("OnTask should append current class, got %#v", got)
|
|
}
|
|
st := c.State()
|
|
if st.Drift == nil || st.Drift.Status != "ontask" {
|
|
t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift)
|
|
}
|
|
}
|
|
```
|
|
|
|
Add a tiny test-only accessor (kept with the controller since the field is unexported) in `session.go`:
|
|
|
|
```go
|
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
|
func (c *Controller) AllowedClassesForTest() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.allowedClasses...)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the tests to verify they fail**
|
|
|
|
Run: `go test ./internal/session/ -run 'TestOnTask|TestStartCommitmentPersistsAllowedClasses' -v`
|
|
Expected: FAIL — undefined `ErrNotActive`, `OnTask`, `AllowedClassesForTest`, and a signature mismatch on `StartManualCommitment`.
|
|
|
|
- [ ] **Step 4: Add fields, constants, and projections to `session.go`**
|
|
|
|
Add `"strings"` to the import block.
|
|
|
|
Add constants/sentinel near `coachTimeout`:
|
|
|
|
```go
|
|
const (
|
|
driftDebounce = 10 * time.Second
|
|
driftTimeout = 30 * time.Second
|
|
)
|
|
|
|
const (
|
|
driftIdle = "idle"
|
|
driftPending = "pending"
|
|
driftOnTask = "ontask"
|
|
driftDrifting = "drifting"
|
|
)
|
|
|
|
var ErrNotActive = errors.New("session: only available while a commitment is active")
|
|
```
|
|
|
|
Add fields to `Controller` (after the coach fields):
|
|
|
|
```go
|
|
allowedClasses []string // durable: the active session's allowed window classes
|
|
judge ai.DriftJudge
|
|
driftStatus string
|
|
driftReason string
|
|
driftGen int
|
|
lastJudgedAt time.Time
|
|
judgedClasses map[string]ai.Verdict
|
|
```
|
|
|
|
Add the projection types:
|
|
|
|
```go
|
|
// DriftView is the active-only drift projection.
|
|
type DriftView struct {
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
```
|
|
|
|
Add to `State`:
|
|
|
|
```go
|
|
Drift *DriftView `json:"drift,omitempty"`
|
|
```
|
|
|
|
Add the allowed-classes field to `ProposalView`:
|
|
|
|
```go
|
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
|
```
|
|
|
|
- [ ] **Step 5: Wire projection, persistence, restore, and reset**
|
|
|
|
In `stateLocked`, inside the coach block where `cv.Proposal` is built, add the field:
|
|
|
|
```go
|
|
cv.Proposal = &ProposalView{
|
|
NextAction: c.coachProposal.NextAction,
|
|
SuccessCondition: c.coachProposal.SuccessCondition,
|
|
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
|
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
|
|
}
|
|
```
|
|
|
|
In `stateLocked`, after the evidence block and before `return st`, add the active-only drift projection:
|
|
|
|
```go
|
|
if c.runtimeState == domain.RuntimeActive {
|
|
status := c.driftStatus
|
|
if status == "" {
|
|
status = driftIdle
|
|
}
|
|
st.Drift = &DriftView{Status: status, Reason: c.driftReason}
|
|
}
|
|
```
|
|
|
|
In `persistLocked`, after setting `snap.SessionID`, persist allowed classes:
|
|
|
|
```go
|
|
snap.AllowedWindowClasses = c.allowedClasses
|
|
```
|
|
|
|
In `New`, inside the `if c.runtimeState == domain.RuntimeActive && s.SessionID != ""` block (before/after `replayStats`), restore allowed classes:
|
|
|
|
```go
|
|
c.allowedClasses = s.AllowedWindowClasses
|
|
```
|
|
|
|
Add a reset helper (mirrors `resetCoachLocked`):
|
|
|
|
```go
|
|
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
|
func (c *Controller) resetDriftLocked() {
|
|
c.driftStatus = driftIdle
|
|
c.driftReason = ""
|
|
c.driftGen++
|
|
c.lastJudgedAt = time.Time{}
|
|
c.judgedClasses = map[string]ai.Verdict{}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Add `SetDriftJudge`, change `StartManualCommitment`, clear on `End`**
|
|
|
|
Add the injector (mirrors `SetCoach`):
|
|
|
|
```go
|
|
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
|
// working; unmatched windows simply stay idle.
|
|
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
|
c.mu.Lock()
|
|
c.judge = j
|
|
c.mu.Unlock()
|
|
}
|
|
```
|
|
|
|
Change the signature and body of `StartManualCommitment`. New signature:
|
|
|
|
```go
|
|
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error {
|
|
```
|
|
|
|
Inside, after `c.resetCoachLocked()`, add:
|
|
|
|
```go
|
|
c.allowedClasses = append([]string(nil), allowedClasses...)
|
|
c.resetDriftLocked()
|
|
```
|
|
|
|
In `End`, after clearing `c.stats = nil`, add:
|
|
|
|
```go
|
|
c.allowedClasses = nil
|
|
c.resetDriftLocked()
|
|
```
|
|
|
|
- [ ] **Step 7: Add `OnTask` and `Refocus`**
|
|
|
|
```go
|
|
// OnTask appends the current window class to the session allowed-context, clears
|
|
// drift, drops any cached verdict for that class, and persists. The class now
|
|
// matches locally and is never re-judged.
|
|
func (c *Controller) OnTask() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.runtimeState != domain.RuntimeActive {
|
|
return ErrNotActive
|
|
}
|
|
var class string
|
|
if c.stats != nil {
|
|
class = c.stats.Current.Class
|
|
}
|
|
if strings.TrimSpace(class) != "" {
|
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
|
if !evidence.MatchesAllowed(ac, class, "") {
|
|
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
|
}
|
|
delete(c.judgedClasses, class)
|
|
}
|
|
c.driftStatus = driftOnTask
|
|
c.driftReason = ""
|
|
return c.persistLocked()
|
|
}
|
|
|
|
// Refocus clears the current drift verdict without changing allowed-context, and
|
|
// drops the cached verdict for the current class so it may be judged again later.
|
|
func (c *Controller) Refocus() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.runtimeState != domain.RuntimeActive {
|
|
return ErrNotActive
|
|
}
|
|
if c.stats != nil {
|
|
delete(c.judgedClasses, c.stats.Current.Class)
|
|
}
|
|
c.driftStatus = driftIdle
|
|
c.driftReason = ""
|
|
return c.persistLocked()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Keep the build green — update existing `StartManualCommitment` callers**
|
|
|
|
In `internal/web/web.go`, `handleCommitment` currently calls:
|
|
|
|
```go
|
|
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second)
|
|
```
|
|
|
|
Change to (temporary `nil`; real wiring in Task 6):
|
|
|
|
```go
|
|
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, nil)
|
|
```
|
|
|
|
In `internal/session/session_test.go`, every existing call to `StartManualCommitment(...)` with three args gains a trailing `, nil`. There are nine such calls (in `TestHappyPathDrivesStates`, `TestStartCommitmentRejectsInvalidInput`, `TestStateRestoresFromSnapshot`, `TestRestoredCommitmentKeepsDeadline`, `TestAccumulatesBucketsAndSwitches`, `TestNoAccountingOutsideActive`, `TestCrashReplayRebuildsStats`, `TestEndWritesAuditSummary`, and `TestLeavingPlanningClearsCoach`). Confirm the full set with:
|
|
|
|
Run: `grep -rn "StartManualCommitment(" internal/`
|
|
Add a trailing `, nil` to each three-arg call (skip the new four-arg calls added in this plan).
|
|
|
|
- [ ] **Step 9: Run the package tests**
|
|
|
|
Run: `go build ./... && go test ./internal/session/ ./internal/web/ -v`
|
|
Expected: PASS, build clean.
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add internal/store/store.go internal/session/session.go internal/web/web.go internal/session/session_test.go
|
|
git commit -m "Add drift state, persistence, and override controls to controller
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Drift pipeline on the `RecordWindow` hot path
|
|
|
|
**Files:**
|
|
- Modify: `internal/session/session.go`
|
|
- Test: `internal/session/session_test.go`
|
|
|
|
Local match first (authoritative on-task, no LLM). Unmatched windows consult the per-class cache, then the debounce window, then launch an async judgment with a generation guard — the M2 `RequestCoach` discipline: the goroutine is launched **after** the mutex is released, and every `notify()` fires with the mutex released. A failed/timed-out judgment never fabricates "drifting"; it reverts the optimistic `pending` to the prior state.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `internal/session/session_test.go` (imports: ensure `sync/atomic` is present):
|
|
|
|
```go
|
|
type fakeJudge struct {
|
|
verdict ai.Verdict
|
|
err error
|
|
gate chan struct{}
|
|
calls int32
|
|
}
|
|
|
|
func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
|
atomic.AddInt32(&f.calls, 1)
|
|
if f.gate != nil {
|
|
<-f.gate
|
|
}
|
|
return f.verdict, f.err
|
|
}
|
|
|
|
func waitDriftStatus(t *testing.T, c *Controller, want string) State {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
st := c.State()
|
|
if st.Drift != nil && st.Drift.Status == want {
|
|
return st
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("drift status never reached %q (last: %+v)", want, c.State().Drift)
|
|
return State{}
|
|
}
|
|
|
|
func startActive(t *testing.T, c *Controller, allowed []string) {
|
|
t.Helper()
|
|
if err := c.EnterPlanning(); err != nil {
|
|
t.Fatalf("planning: %v", err)
|
|
}
|
|
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
}
|
|
|
|
func obs(class, title string) evidence.WindowSnapshot {
|
|
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
|
|
}
|
|
|
|
func TestLocalMatchIsOnTaskWithoutJudge(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}
|
|
c.SetDriftJudge(fj)
|
|
startActive(t, c, []string{"code"})
|
|
c.RecordWindow(obs("Code", "main.go"))
|
|
st := c.State()
|
|
if st.Drift == nil || st.Drift.Status != "ontask" {
|
|
t.Fatalf("local match should be on-task, got %+v", st.Drift)
|
|
}
|
|
if atomic.LoadInt32(&fj.calls) != 0 {
|
|
t.Fatalf("judge must not be called on a local match")
|
|
}
|
|
}
|
|
|
|
func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "YouTube is off-task"}})
|
|
startActive(t, c, []string{"code"})
|
|
c.RecordWindow(obs("firefox", "YouTube"))
|
|
st := waitDriftStatus(t, c, "drifting")
|
|
if st.Drift.Reason != "YouTube is off-task" {
|
|
t.Fatalf("reason not surfaced: %+v", st.Drift)
|
|
}
|
|
}
|
|
|
|
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}
|
|
c.SetDriftJudge(fj)
|
|
startActive(t, c, []string{"code"})
|
|
c.RecordWindow(obs("firefox", "YouTube"))
|
|
waitDriftStatus(t, c, "drifting")
|
|
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
|
t.Fatalf("cached class should not re-judge, calls=%d", got)
|
|
}
|
|
}
|
|
|
|
func TestDebounceLimitsJudgeCalls(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
now := time.Now()
|
|
c.SetClock(func() time.Time { return now })
|
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
|
|
c.SetDriftJudge(fj)
|
|
startActive(t, c, []string{"code"})
|
|
c.RecordWindow(obs("firefox", "a")) // launches one (blocked on gate)
|
|
c.RecordWindow(obs("chrome", "b")) // within debounce window -> suppressed
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
|
t.Fatalf("debounce should allow one judge call, calls=%d", got)
|
|
}
|
|
close(fj.gate)
|
|
}
|
|
|
|
func TestStaleJudgmentDiscardedAfterEnd(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
|
|
c.SetDriftJudge(fj)
|
|
startActive(t, c, []string{"code"})
|
|
c.RecordWindow(obs("firefox", "YouTube")) // launches, blocks on gate
|
|
waitDriftStatus(t, c, "pending")
|
|
_ = c.Complete()
|
|
_ = c.End()
|
|
close(fj.gate) // release stale goroutine; must be discarded
|
|
time.Sleep(50 * time.Millisecond)
|
|
if c.State().RuntimeState != domain.RuntimeLocked {
|
|
t.Fatalf("should be Locked")
|
|
}
|
|
}
|
|
|
|
func TestNilJudgeLeavesUnmatchedIdle(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
startActive(t, c, []string{"code"})
|
|
c.RecordWindow(obs("firefox", "YouTube"))
|
|
st := c.State()
|
|
if st.Drift == nil || st.Drift.Status != "idle" {
|
|
t.Fatalf("nil judge should leave unmatched idle, got %+v", st.Drift)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `go test ./internal/session/ -run 'TestLocalMatch|TestUnmatched|TestPerClassCache|TestDebounce|TestStaleJudgment|TestNilJudge' -v`
|
|
Expected: FAIL — drift status stays idle / `pending` never reached (pipeline not wired).
|
|
|
|
- [ ] **Step 3: Wire the pipeline into `RecordWindow`**
|
|
|
|
Replace the body of `RecordWindow` with:
|
|
|
|
```go
|
|
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|
c.mu.Lock()
|
|
c.latestWindow = snap
|
|
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
return
|
|
}
|
|
now := c.clock()
|
|
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
|
c.applyEvent(now, snap)
|
|
launch := c.evaluateDriftLocked(now, snap)
|
|
c.mu.Unlock()
|
|
if launch != nil {
|
|
launch()
|
|
}
|
|
c.notify()
|
|
}
|
|
```
|
|
|
|
Add the pipeline helpers:
|
|
|
|
```go
|
|
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
|
// and updates synchronous drift state. When an async judgment is warranted it
|
|
// returns a closure that launches it; the caller invokes the closure after
|
|
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
|
// already verified the runtime is Active.
|
|
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.
|
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
|
if evidence.MatchesAllowed(ac, class, title) {
|
|
c.driftStatus = driftOnTask
|
|
c.driftReason = ""
|
|
return nil
|
|
}
|
|
|
|
// 2. Per-class cache.
|
|
if v, ok := c.judgedClasses[class]; ok {
|
|
c.applyVerdictLocked(v)
|
|
return nil
|
|
}
|
|
|
|
// 3. No judge wired: never block; leave idle.
|
|
if c.judge == nil {
|
|
c.driftStatus = driftIdle
|
|
c.driftReason = ""
|
|
return nil
|
|
}
|
|
|
|
// 4. Debounce: at most one judgment per driftDebounce window.
|
|
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
|
|
return nil
|
|
}
|
|
|
|
prevStatus, prevReason := c.driftStatus, c.driftReason
|
|
c.driftGen++
|
|
gen := c.driftGen
|
|
c.lastJudgedAt = now
|
|
c.driftStatus = driftPending
|
|
c.driftReason = ""
|
|
judge := c.judge
|
|
commitment := ""
|
|
if c.commitment != nil {
|
|
commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
|
}
|
|
|
|
return func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
|
defer cancel()
|
|
v, err := judge.JudgeDrift(ctx, commitment, class, title)
|
|
|
|
c.mu.Lock()
|
|
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
|
c.mu.Unlock()
|
|
return // stale: a newer judgment started or the session ended
|
|
}
|
|
if err != nil {
|
|
// Degrade: never fabricate drift. Revert the optimistic pending.
|
|
if c.driftStatus == driftPending {
|
|
c.driftStatus = prevStatus
|
|
c.driftReason = prevReason
|
|
}
|
|
c.mu.Unlock()
|
|
log.Printf("session: drift judge failed: %v", err)
|
|
c.notify()
|
|
return
|
|
}
|
|
c.judgedClasses[class] = v
|
|
if c.stats != nil && c.stats.Current.Class == class {
|
|
c.applyVerdictLocked(v)
|
|
}
|
|
c.mu.Unlock()
|
|
c.notify()
|
|
}
|
|
}
|
|
|
|
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
|
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
|
|
if v.OnTask {
|
|
c.driftStatus = driftOnTask
|
|
c.driftReason = ""
|
|
return
|
|
}
|
|
c.driftStatus = driftDrifting
|
|
c.driftReason = v.Reason
|
|
}
|
|
```
|
|
|
|
Note: `c.judgedClasses` is always non-nil while Active because `resetDriftLocked` (called by `StartManualCommitment`) initializes it.
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `go test ./internal/session/ -race -v`
|
|
Expected: PASS, no data races.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/session/session.go internal/session/session_test.go
|
|
git commit -m "Wire debounced cached async drift judgment into RecordWindow
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: `web` layer — commitment classes and override routes
|
|
|
|
**Files:**
|
|
- Modify: `internal/web/web.go`
|
|
- Test: `internal/web/web_test.go`
|
|
|
|
`ErrNotActive` is not an `IllegalTransitionError`, so the existing `respond` helper maps it to HTTP 400 — exactly what we want.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
`internal/web/web_test.go` already exists and provides `newTestServer(t)` and a `post(t, r, path, body)` helper that returns an `*httptest.ResponseRecorder`. Reuse them — do NOT redefine them. Append these tests:
|
|
|
|
```go
|
|
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
|
|
}
|
|
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
|
s := newTestServer(t)
|
|
r := s.Router()
|
|
_ = post(t, r, "/planning", "")
|
|
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
|
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
|
t.Fatalf("commitment did not carry allowed classes: %#v", got)
|
|
}
|
|
// Now Active: overrides succeed.
|
|
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
|
|
}
|
|
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
|
|
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `go test ./internal/web/ -run 'TestRefocus|TestCommitmentCarries' -v`
|
|
Expected: FAIL — routes 404, and `allowed_window_classes` is dropped.
|
|
|
|
- [ ] **Step 3: Add the request field, wire it, add routes**
|
|
|
|
In `internal/web/web.go`, extend `commitmentRequest`:
|
|
|
|
```go
|
|
type commitmentRequest struct {
|
|
NextAction string `json:"next_action"`
|
|
SuccessCondition string `json:"success_condition"`
|
|
TimeboxSecs int64 `json:"timebox_secs"`
|
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
|
}
|
|
```
|
|
|
|
In `handleCommitment`, pass the classes (replacing the `nil` from Task 4):
|
|
|
|
```go
|
|
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses)
|
|
```
|
|
|
|
Register routes in `Router()` (after `/complete`):
|
|
|
|
```go
|
|
r.POST("/refocus", s.handleRefocus)
|
|
r.POST("/ontask", s.handleOnTask)
|
|
```
|
|
|
|
Add the handlers:
|
|
|
|
```go
|
|
func (s *Server) handleRefocus(c *gin.Context) {
|
|
s.respond(c, s.ctrl.Refocus())
|
|
}
|
|
|
|
func (s *Server) handleOnTask(c *gin.Context) {
|
|
s.respond(c, s.ctrl.OnTask())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `go test ./internal/web/ -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/web/web.go internal/web/web_test.go
|
|
git commit -m "Add /refocus and /ontask routes and carry allowed classes
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: UI — allowed-apps field and active-view drift interrupt
|
|
|
|
**Files:**
|
|
- Modify: `internal/web/static/index.html`
|
|
|
|
No Go tests here; verification is `go build ./...` (the file is embedded) plus the manual smoke at the end. Add an active-view fast-path mirroring the planning one so SSE ticks update the drift region without tearing down the countdown timer. The interrupt is non-modal — it cannot lock the user out (enforcement is M8).
|
|
|
|
- [ ] **Step 1: Add drift CSS**
|
|
|
|
In the `<style>` block, after the `.switches` rule, add:
|
|
|
|
```css
|
|
.drift { background: #2a1d22; border: 1px solid #5a2d39; border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
|
|
.drift-title { font-weight: 700; color: #f0a3b1; }
|
|
.drift-reason { color: #e6c0c8; margin: 4px 0 10px; }
|
|
.drift-actions button { margin: 0 8px 0 0; padding: 8px 14px; }
|
|
.drift-actions button.secondary { background: #2d3242; color: #cdd2e0; }
|
|
.drift-hint { font-size: 12px; color: #7a8095; margin-bottom: 10px; }
|
|
```
|
|
|
|
- [ ] **Step 2: Add the active-view fast-path**
|
|
|
|
In `render(state)`, immediately after the existing planning fast-path block, add an active fast-path:
|
|
|
|
```js
|
|
if (rs === 'active' && renderedState === 'active') {
|
|
updateActiveDrift(state.drift);
|
|
return;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add `updateActiveDrift` and render the drift container**
|
|
|
|
Add the function near `updatePlanningCoach`:
|
|
|
|
```js
|
|
function updateActiveDrift(drift) {
|
|
const el = document.getElementById('drift');
|
|
if (!el) return;
|
|
const status = drift && drift.status;
|
|
if (status === 'drifting') {
|
|
el.innerHTML = `<div class="drift">
|
|
<div class="drift-title">⚠ Possible drift</div>
|
|
<div class="drift-reason">${drift.reason || ''}</div>
|
|
<div class="drift-actions">
|
|
<button id="refocus" type="button">Back to task</button>
|
|
<button id="ontask" type="button" class="secondary">This is on task</button>
|
|
<button id="enddrift" type="button" class="secondary">End session</button>
|
|
</div></div>`;
|
|
document.getElementById('refocus').onclick = () => post('/refocus');
|
|
document.getElementById('ontask').onclick = () => post('/ontask');
|
|
document.getElementById('enddrift').onclick = () => post('/complete');
|
|
} else if (status === 'pending') {
|
|
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
|
} else {
|
|
el.innerHTML = '';
|
|
}
|
|
}
|
|
```
|
|
|
|
In the active branch of `render`, add a drift container above the timer and call the updater after wiring the existing controls. Change the active `view.innerHTML` to include `<div id="drift"></div>` as the first child:
|
|
|
|
```js
|
|
view.innerHTML = `<span class="pill">Active</span>
|
|
<div id="drift"></div>
|
|
<div class="timer" id="t">--:--</div>
|
|
<div class="action">${c.next_action || ''}</div>
|
|
<p class="meta">Done when: ${c.success_condition || ''}</p>
|
|
${evidenceBlock(state.evidence)}
|
|
<button id="done">Complete</button>`;
|
|
```
|
|
|
|
and after `countdownTimer = setInterval(tick, 250);` add:
|
|
|
|
```js
|
|
updateActiveDrift(state.drift);
|
|
```
|
|
|
|
- [ ] **Step 4: Add the planning "allowed apps" field**
|
|
|
|
In the planning branch `view.innerHTML`, add an input after the Minutes input and before the Start button:
|
|
|
|
```js
|
|
<label>Allowed apps (comma-separated window classes)</label>
|
|
<input id="apps" placeholder="e.g. code, firefox">
|
|
```
|
|
|
|
Update the Start POST to include the parsed list:
|
|
|
|
```js
|
|
start.onclick = () => post('/commitment', {
|
|
next_action: na.value.trim(),
|
|
success_condition: sc.value.trim(),
|
|
timebox_secs: Math.round(+mins.value * 60),
|
|
allowed_window_classes: (document.getElementById('apps').value || '')
|
|
.split(',').map(s => s.trim()).filter(Boolean),
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Pre-fill allowed apps from the coach proposal**
|
|
|
|
In `updatePlanningCoach`, inside the `ready` branch after the existing pre-fill of `na`/`sc`/`mins` (still guarded by the `dataset.applied` stamp so it only runs once and won't clobber typed text), add:
|
|
|
|
```js
|
|
const apps = document.getElementById('apps');
|
|
if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
|
|
apps.value = coach.proposal.allowed_window_classes.join(', ');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Verify build and manual smoke**
|
|
|
|
Run: `go build ./...`
|
|
Expected: clean.
|
|
|
|
Manual smoke (documented; run by the human): start the daemon, plan a session with allowed app `code`, start it, switch focus to an unrelated app, confirm the drift interrupt appears, click "This is on task" and confirm it clears and the class stops re-triggering; repeat and click "Back to task" to confirm it dismisses.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add internal/web/static/index.html
|
|
git commit -m "Add allowed-apps field and active-view drift interrupt UI
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Daemon wiring
|
|
|
|
**Files:**
|
|
- Modify: `cmd/antidriftd/main.go`
|
|
|
|
The single `Service` implements both `Coach` and `DriftJudge`; wire it into both.
|
|
|
|
- [ ] **Step 1: Wire `SetDriftJudge`**
|
|
|
|
Replace the AI-wiring block (currently lines ~38-43):
|
|
|
|
```go
|
|
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
|
|
log.Printf("ai coach disabled: %v", err)
|
|
} else {
|
|
ctrl.SetCoach(ai.NewService(backend))
|
|
log.Printf("ai coach: %s backend", backend.Name())
|
|
}
|
|
```
|
|
|
|
with:
|
|
|
|
```go
|
|
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
|
|
log.Printf("ai disabled: %v", err)
|
|
} else {
|
|
svc := ai.NewService(backend)
|
|
ctrl.SetCoach(svc)
|
|
ctrl.SetDriftJudge(svc)
|
|
log.Printf("ai: %s backend (coach + drift judge)", backend.Name())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
Run: `go build ./... && go vet ./...`
|
|
Expected: clean.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add cmd/antidriftd/main.go
|
|
git commit -m "Wire drift judge into the daemon alongside the coach
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: README / roadmap note
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
|
|
- [ ] **Step 1: Update the Status section**
|
|
|
|
In `README.md`, add an M3 paragraph above the M2 paragraph in the `## Status` section:
|
|
|
|
```markdown
|
|
M3 (drift interceptor): while a commitment is Active, the daemon watches the
|
|
focused window. A cheap local match against the session's allowed window classes
|
|
is authoritative for on-task; only unmatched windows are sent to the LLM drift
|
|
judge (debounced and cached per class, run asynchronously). When it judges you
|
|
off-task, the active view shows a dismissible interrupt: "Back to task", "This is
|
|
on task" (which adds the app to the session's allowed list), or "End session".
|
|
The drift judge degrades gracefully — without it, local matching still runs.
|
|
```
|
|
|
|
- [ ] **Step 2: Full test sweep**
|
|
|
|
Run: `go test -race ./... && go vet ./...`
|
|
Expected: all PASS, no races, clean vet.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "Document M3 drift interceptor in README
|
|
|
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Final Review
|
|
|
|
After all tasks: dispatch a final code reviewer over the entire M3 diff (`git diff main...HEAD` if on a branch, or the M3 commit range), then use superpowers:finishing-a-development-branch.
|
|
|
|
## Self-Review Notes (author)
|
|
|
|
- **Spec coverage:** evidence matching (T1), `DriftJudge`/`Verdict`/`parseVerdict`/`buildDriftPrompt`/`JudgeDrift` (T2), coach allowed-classes (T3), controller fields/projection/persistence/overrides/signature (T4), `RecordWindow` debounced-cached-async pipeline (T5), web request field + `/refocus` + `/ontask` (T6), planning allowed-apps + active interrupt (T7), daemon `SetDriftJudge` (T8), README (T9). All spec sections map to a task.
|
|
- **Type consistency:** `MatchesAllowed(domain.AllowedContext, class, title)`, `JudgeDrift(ctx, commitment, class, title)`, `Verdict{OnTask, Reason}`, `Proposal.AllowedWindowClasses`, `StartManualCommitment(..., allowedClasses []string)`, drift status strings `idle|pending|ontask|drifting`, and `State.Drift *DriftView` are used identically across tasks.
|
|
- **Build-green discipline:** the `StartManualCommitment` signature change (T4) updates the `web.go` call site to `nil` and existing test callers in the same commit; T6 swaps `nil` for the real field. Each commit builds and tests clean.
|
|
- **Deliberate refinements:** drift *verdict* is not persisted (recomputes after restart, avoiding stale interrupts); `parseVerdict` rejects reasonless drift; judge failure reverts `pending` rather than fabricating drift.
|