diff --git a/docs/superpowers/plans/2026-05-31-m1-evidence-audit.md b/docs/superpowers/plans/2026-05-31-m1-evidence-audit.md new file mode 100644 index 0000000..4b86ff4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-m1-evidence-audit.md @@ -0,0 +1,1744 @@ +# M1 — Evidence & Audit 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:** Give the daemon an active-window sensor, a two-tier evidence store (disposable per-session raw log + permanent hash-chained session summaries), live SSE updates of what the user is doing, and crash-recovery of in-session stats. + +**Architecture:** A new `evidence` port (`Source` interface + X11/xgbutil adapter + types + `ScrubTitle`) pushes `WindowSnapshot`s into `session.Controller`, which accumulates per-bucket time while `Active`, persists every focus change to a raw per-session JSONL, and seals each finished session into `audit.jsonl` as one SHA-256-chained `SessionSummary`. The controller gets an injectable clock and rebuilds stats from the raw log on restart. `web` surfaces the evidence projection over the existing SSE stream. + +**Tech Stack:** Go 1.26.3, Gin, `github.com/google/uuid` (UUIDv7), `github.com/jezek/xgb` + `github.com/jezek/xgbutil` (pure-Go X11), `crypto/sha256`, `encoding/json`. + +--- + +## Design decisions locked in (read before starting) + +These resolve ambiguities in the spec; implement exactly as written. + +1. **`onChange` fires only from `RecordWindow`.** It is the one state-change path that originates outside an HTTP handler (the evidence goroutine). HTTP user actions continue to broadcast via the existing `web.respond()`; the expiry timer keeps its explicit `broadcast()` after `Expire()`. This avoids a lock-reentry deadlock (`onChange → broadcast → ctrl.State() → c.mu`). `RecordWindow` always releases the mutex *before* calling `notify()`. +2. **A seed `FocusEvent` is appended at session start.** `StartManualCommitment` writes one `FocusEvent` for the current window at the start instant, so the raw log alone fully reconstructs stats on crash (session start time = first event's timestamp). One private `applyEvent` routine serves both live tracking and replay. +3. **The injectable clock** (`c.clock func() time.Time`, default `time.Now`) is used for *every* timestamp and duration in the controller (deadline, `StartedUnix`, segment accumulation, `EndedUnix`). Tests set it via `SetClock`. +4. **Outcome ("completed" vs "expired")** is tracked by `c.outcomePending`, set when entering Review: user `Complete()` → `"completed"`, timer/startup `Expire()` → `"expired"`. The underlying commitment transition is identical for both (`statemachine.Complete`); only the audit `Outcome` field differs. Changing the commitment state machine is out of M1 scope. +5. **Store paths** are derived from the snapshot path's directory: `audit.jsonl` and `sessions/` live beside `state.json`. `New(path)`'s signature is unchanged; tests get isolation for free via `t.TempDir()`. + +--- + +## File Structure + +- Create `internal/evidence/evidence.go` — `EvidenceHealth`, `WindowSnapshot`, `Source`, `ScrubTitle`. +- Create `internal/evidence/evidence_test.go` — `ScrubTitle` table tests + a fake `Source`. +- Create `internal/evidence/x11.go` — `x11Source` + `NewSource()` (`//go:build linux`). +- Create `internal/evidence/source_other.go` — `NewSource()` fallback (`//go:build !linux`). +- Create `internal/evidence/x11_integration_test.go` — DISPLAY-gated live smoke test (`//go:build linux`). +- Create `internal/store/audit.go` — `BucketTotal`, `SessionSummary`, `AppendSession`, `VerifyChain`. +- Create `internal/store/audit_test.go`. +- Create `internal/store/evidence_log.go` — `FocusEvent`, `AppendFocus`, `ReplaySession`, `PruneOlderThan`. +- Create `internal/store/evidence_log_test.go`. +- Modify `internal/store/store.go` — `Snapshot` gains `SessionID`, `OutcomePending`. +- Modify `internal/session/session.go` — clock, stats, `RecordWindow`, lifecycle hooks, `SetOnChange`, recovery, evidence projection in `State`. +- Modify `internal/session/session_test.go` — accumulation / replay / audit-at-End tests. +- Modify `internal/web/web.go` — register `onChange`, switch timer + Init to `Expire()`. +- Modify `internal/web/web_test.go` — evidence payload test. +- Modify `internal/web/static/index.html` — render evidence in Active + Review views. +- Modify `cmd/antidriftd/main.go` — construct `evidence.NewSource()`, start `Watch` goroutine wired to `ctrl.RecordWindow`. +- Modify `go.mod` / `go.sum` — add `github.com/jezek/xgb`, `github.com/jezek/xgbutil`. + +--- + +## Task 1: Add X11 dependencies + +**Files:** +- Modify: `go.mod`, `go.sum` + +- [ ] **Step 1: Fetch the dependencies** + +Run: +```bash +go get github.com/jezek/xgbutil@latest github.com/jezek/xgb@latest +``` +Expected: `go.mod` gains `github.com/jezek/xgbutil` and `github.com/jezek/xgb` lines (xgb may be indirect until Task 6 imports it). + +- [ ] **Step 2: Verify the module still builds** + +Run: `go build ./...` +Expected: success, no output. + +- [ ] **Step 3: Commit** + +```bash +git add go.mod go.sum +git commit -m "M1: add jezek/xgbutil + xgb dependencies" +``` + +--- + +## Task 2: `evidence` types and `ScrubTitle` + +**Files:** +- Create: `internal/evidence/evidence.go` +- Test: `internal/evidence/evidence_test.go` + +- [ ] **Step 1: Write the failing tests** + +`internal/evidence/evidence_test.go`: +```go +package evidence + +import ( + "context" + "testing" +) + +func TestScrubTitle(t *testing.T) { + cases := []struct{ in, want string }{ + {"Plain title", "Plain title"}, // no digits-with-separator: untouched + {"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory) + {"12.5%", ""}, // percent decimal: stripped whole + {"1:23:45 remaining", " remaining"}, // clock ratio: stripped + {"-3.0 delta", " delta"}, // leading sign + decimal: stripped + {"Download 50.5% complete", "Download complete"}, // embedded percent decimal + } + for _, c := range cases { + if got := ScrubTitle(c.in); got != c.want { + t.Errorf("ScrubTitle(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// fakeSource satisfies Source for downstream tests and proves the interface shape. +type fakeSource struct{ snaps []WindowSnapshot } + +func (f fakeSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) { + for _, s := range f.snaps { + onChange(s) + } + <-ctx.Done() +} + +func TestFakeSourceEmits(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var got []WindowSnapshot + src := fakeSource{snaps: []WindowSnapshot{ + {Title: "a", Class: "code", Health: EvidenceHealth{Available: true}}, + }} + go src.Watch(ctx, func(s WindowSnapshot) { got = append(got, s) }) + // Give the goroutine a chance, then cancel. + cancel() + // We can't assert timing deterministically here; this test only confirms the + // types and signature compile and Watch accepts the callback. + _ = got +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/evidence/` +Expected: FAIL — `undefined: ScrubTitle`, `undefined: WindowSnapshot`, etc. + +- [ ] **Step 3: Implement `evidence.go`** + +`internal/evidence/evidence.go`: +```go +// Package evidence is the activity port: a dumb active-window sensor behind a +// Source interface, plus the value types it emits. It makes no judgment about +// whether a window is on-task — that is the advisor's job in a later milestone. +package evidence + +import ( + "context" + "regexp" +) + +// EvidenceHealth records whether the sensor could observe the active window. +type EvidenceHealth struct { + Available bool // true when a window was observed + Reason string // empty when Available; populated when not +} + +// WindowSnapshot is one observation of the active window. +type WindowSnapshot struct { + Title string // full _NET_WM_NAME (raw; used in live view + raw log) + Class string // WM_CLASS + Health EvidenceHealth +} + +// Source is the activity port. Watch runs until ctx is cancelled, invoking +// onChange on every active-window change, and once immediately with the +// current window. +type Source interface { + Watch(ctx context.Context, onChange func(WindowSnapshot)) +} + +// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket +// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl. +var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`) + +// ScrubTitle removes volatile numeric runs so window titles bucket stably. +// The raw event log keeps the unscrubbed title; only bucket keys are scrubbed. +func ScrubTitle(title string) string { + return titleNoise.ReplaceAllString(title, "") +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `go test ./internal/evidence/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/evidence/evidence.go internal/evidence/evidence_test.go +git commit -m "M1: evidence types, Source port, ScrubTitle" +``` + +--- + +## Task 3: `store/audit.go` — hash-chained session summaries + +**Files:** +- Create: `internal/store/audit.go` +- Test: `internal/store/audit_test.go` + +- [ ] **Step 1: Write the failing tests** + +`internal/store/audit_test.go`: +```go +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func auditFixture(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "audit.jsonl") +} + +func sampleSummary(id string) SessionSummary { + return SessionSummary{ + SessionID: id, + NextAction: "write plan", + SuccessCond: "plan committed", + Outcome: "completed", + StartedUnix: 1000, + EndedUnix: 2000, + SwitchCount: 3, + Buckets: []BucketTotal{ + {Class: "code", Title: "antidrift", Seconds: 540}, + {Class: "firefox", Title: "docs", Seconds: 120}, + }, + } +} + +func TestAppendSessionChainsAndVerifies(t *testing.T) { + path := auditFixture(t) + for i := 0; i < 3; i++ { + if err := AppendSession(path, sampleSummary("session-"+string(rune('a'+i)))); err != nil { + t.Fatalf("append %d: %v", i, err) + } + } + if err := VerifyChain(path); err != nil { + t.Fatalf("chain should verify: %v", err) + } +} + +func TestGenesisPrevHashIsZeros(t *testing.T) { + path := auditFixture(t) + if err := AppendSession(path, sampleSummary("session-a")); err != nil { + t.Fatalf("append: %v", err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), strings.Repeat("0", 64)) { + t.Fatalf("genesis prev_hash should be 64 zeros, got: %s", data) + } +} + +func TestVerifyEmptyChain(t *testing.T) { + if err := VerifyChain(auditFixture(t)); err != nil { + t.Fatalf("empty/missing chain should verify, got %v", err) + } +} + +func TestTamperDetected(t *testing.T) { + path := auditFixture(t) + _ = AppendSession(path, sampleSummary("session-a")) + _ = AppendSession(path, sampleSummary("session-b")) + _ = AppendSession(path, sampleSummary("session-c")) + + // Corrupt the middle line's switch_count. + data, _ := os.ReadFile(path) + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + lines[1] = strings.Replace(lines[1], `"switch_count":3`, `"switch_count":99`, 1) + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatalf("rewrite: %v", err) + } + err := VerifyChain(path) + if err == nil || !strings.Contains(err.Error(), "line 2") { + t.Fatalf("tamper on line 2 should be reported, got %v", err) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/store/ -run 'Audit|Chain|Genesis|Tamper'` +Expected: FAIL — `undefined: AppendSession`, `undefined: VerifyChain`, `undefined: SessionSummary`. + +- [ ] **Step 3: Implement `audit.go`** + +`internal/store/audit.go`: +```go +package store + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" +) + +const genesisHash = "0000000000000000000000000000000000000000000000000000000000000000" + +// BucketTotal is per-(class, scrubbed-title) accumulated time in a session. +type BucketTotal struct { + Class string `json:"class"` + Title string `json:"title"` // scrubbed + Seconds int64 `json:"seconds"` +} + +// SessionSummary is one permanent, hash-chained record of a finished session. +type SessionSummary struct { + Seq int `json:"seq"` + PrevHash string `json:"prev_hash"` + SessionID string `json:"session_id"` + NextAction string `json:"next_action"` + SuccessCond string `json:"success_condition"` + Outcome string `json:"outcome"` // "completed" | "expired" + StartedUnix int64 `json:"started_unix"` + EndedUnix int64 `json:"ended_unix"` + SwitchCount int `json:"switch_count"` + Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds + Hash string `json:"hash"` +} + +// computeHash returns SHA-256(prevHash || canonicalJSON(s with Hash zeroed)). +// encoding/json emits struct fields in declaration order, so this is stable. +func computeHash(s SessionSummary) (string, error) { + s.Hash = "" + canonical, err := json.Marshal(s) + if err != nil { + return "", err + } + h := sha256.New() + h.Write([]byte(s.PrevHash)) + h.Write(canonical) + return hex.EncodeToString(h.Sum(nil)), nil +} + +func readSummaries(path string) ([]SessionSummary, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + var out []SessionSummary + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var s SessionSummary + if err := json.Unmarshal([]byte(line), &s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, sc.Err() +} + +// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns +// the next contiguous Seq, computes its Hash, and appends one JSON line with an +// fsync. +func AppendSession(path string, s SessionSummary) error { + existing, err := readSummaries(path) + if err != nil { + return err + } + if len(existing) == 0 { + s.Seq = 1 + s.PrevHash = genesisHash + } else { + last := existing[len(existing)-1] + s.Seq = last.Seq + 1 + s.PrevHash = last.Hash + } + hash, err := computeHash(s) + if err != nil { + return err + } + s.Hash = hash + + line, err := json.Marshal(s) + if err != nil { + return err + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Write(append(line, '\n')); err != nil { + return err + } + return f.Sync() +} + +// VerifyChain re-walks every line, recomputing hashes and confirming each +// prev_hash links to the prior hash and Seq is contiguous from 1. It returns an +// error naming the first broken line (1-based); nil if intact or empty. +func VerifyChain(path string) error { + summaries, err := readSummaries(path) + if err != nil { + return err + } + prev := genesisHash + for i, s := range summaries { + lineNo := i + 1 + if s.Seq != lineNo { + return fmt.Errorf("audit chain broken at line %d: seq %d, want %d", lineNo, s.Seq, lineNo) + } + if s.PrevHash != prev { + return fmt.Errorf("audit chain broken at line %d: prev_hash mismatch", lineNo) + } + want, err := computeHash(s) + if err != nil { + return err + } + if want != s.Hash { + return fmt.Errorf("audit chain broken at line %d: hash mismatch", lineNo) + } + prev = s.Hash + } + return nil +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `go test ./internal/store/ -run 'Audit|Chain|Genesis|Tamper'` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/audit.go internal/store/audit_test.go +git commit -m "M1: hash-chained session audit log" +``` + +--- + +## Task 4: `store/evidence_log.go` — raw per-session focus log + +**Files:** +- Create: `internal/store/evidence_log.go` +- Test: `internal/store/evidence_log_test.go` + +- [ ] **Step 1: Write the failing tests** + +`internal/store/evidence_log_test.go`: +```go +package store + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAppendFocusReplayRoundTrips(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sessions") + id := "session-x" + events := []FocusEvent{ + {AtUnixMillis: 1000, Class: "code", Title: "a", Available: true}, + {AtUnixMillis: 2000, Class: "firefox", Title: "b", Available: true}, + {AtUnixMillis: 3000, Class: "", Title: "", Available: false, Reason: "no active window"}, + } + for _, e := range events { + if err := AppendFocus(dir, id, e); err != nil { + t.Fatalf("append: %v", err) + } + } + got, err := ReplaySession(dir, id) + if err != nil { + t.Fatalf("replay: %v", err) + } + if len(got) != len(events) { + t.Fatalf("got %d events, want %d", len(got), len(events)) + } + for i := range events { + if got[i] != events[i] { + t.Errorf("event %d: got %+v want %+v", i, got[i], events[i]) + } + } +} + +func TestReplayMissingSessionIsEmpty(t *testing.T) { + got, err := ReplaySession(filepath.Join(t.TempDir(), "sessions"), "nope") + if err != nil { + t.Fatalf("missing session should not error: %v", err) + } + if len(got) != 0 { + t.Fatalf("missing session should be empty, got %d", len(got)) + } +} + +func TestPruneOlderThan(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sessions") + _ = AppendFocus(dir, "old", FocusEvent{AtUnixMillis: 1}) + _ = AppendFocus(dir, "new", FocusEvent{AtUnixMillis: 1}) + + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + oldTime := now.Add(-40 * 24 * time.Hour) + if err := os.Chtimes(filepath.Join(dir, "old.jsonl"), oldTime, oldTime); err != nil { + t.Fatalf("chtimes: %v", err) + } + + if err := PruneOlderThan(dir, 30*24*time.Hour, now); err != nil { + t.Fatalf("prune: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "old.jsonl")); !os.IsNotExist(err) { + t.Errorf("old session should be pruned") + } + if _, err := os.Stat(filepath.Join(dir, "new.jsonl")); err != nil { + t.Errorf("new session should survive: %v", err) + } +} + +func TestPruneMissingDirIsNoop(t *testing.T) { + if err := PruneOlderThan(filepath.Join(t.TempDir(), "nope"), time.Hour, time.Now()); err != nil { + t.Fatalf("missing dir should be a no-op, got %v", err) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/store/ -run 'Focus|Replay|Prune'` +Expected: FAIL — `undefined: FocusEvent`, `undefined: AppendFocus`, etc. + +- [ ] **Step 3: Implement `evidence_log.go`** + +`internal/store/evidence_log.go`: +```go +package store + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +// FocusEvent is one raw, disposable observation in a session's firehose log. +type FocusEvent struct { + AtUnixMillis int64 `json:"at_unix_millis"` + Class string `json:"class"` + Title string `json:"title"` // full, unscrubbed + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` +} + +func sessionFile(dir, sessionID string) string { + return filepath.Join(dir, sessionID+".jsonl") +} + +// AppendFocus appends one event to dir/.jsonl, creating dir if +// needed. It is best-effort detail, not the state of truth. +func AppendFocus(dir, sessionID string, e FocusEvent) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + line, err := json.Marshal(e) + if err != nil { + return err + } + f, err := os.OpenFile(sessionFile(dir, sessionID), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(append(line, '\n')) + return err +} + +// ReplaySession reads all events for a session in append order. A missing file +// yields an empty slice and no error. +func ReplaySession(dir, sessionID string) ([]FocusEvent, error) { + f, err := os.Open(sessionFile(dir, sessionID)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + var out []FocusEvent + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var e FocusEvent + if err := json.Unmarshal([]byte(line), &e); err != nil { + return nil, err + } + out = append(out, e) + } + return out, sc.Err() +} + +// PruneOlderThan deletes session files whose modification time precedes +// now-age. A missing dir is a no-op. now is injected for testability. +func PruneOlderThan(dir string, age time.Duration, now time.Time) error { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + cutoff := now.Add(-age) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + return nil +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `go test ./internal/store/ -run 'Focus|Replay|Prune'` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/evidence_log.go internal/store/evidence_log_test.go +git commit -m "M1: raw per-session focus log with retention prune" +``` + +--- + +## Task 5: Snapshot gains `SessionID` and `OutcomePending` + +**Files:** +- Modify: `internal/store/store.go` +- Test: `internal/store/store_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/store/store_test.go`: +```go +func TestSnapshotCarriesSessionFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + want := Snapshot{ + RuntimeState: domain.RuntimeActive, + SessionID: "session-abc", + OutcomePending: "completed", + } + if err := Save(path, want); err != nil { + t.Fatalf("save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.SessionID != "session-abc" || got.OutcomePending != "completed" { + t.Fatalf("session fields did not round-trip: %+v", got) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/store/ -run SnapshotCarriesSessionFields` +Expected: FAIL — `unknown field SessionID in struct literal`. + +- [ ] **Step 3: Add the fields** + +In `internal/store/store.go`, extend `Snapshot`: +```go +// Snapshot is the persisted current state of the daemon. +type Snapshot struct { + RuntimeState domain.RuntimeState `json:"runtime_state"` + Commitment *domain.Commitment `json:"commitment,omitempty"` + DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"` + SessionID string `json:"session_id,omitempty"` + OutcomePending string `json:"outcome_pending,omitempty"` +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `go test ./internal/store/` +Expected: PASS (all store tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/store.go internal/store/store_test.go +git commit -m "M1: snapshot carries session_id and outcome_pending" +``` + +--- + +## Task 6: X11 adapter + platform fallback + +**Files:** +- Create: `internal/evidence/x11.go` (`//go:build linux`) +- Create: `internal/evidence/source_other.go` (`//go:build !linux`) +- Create: `internal/evidence/x11_integration_test.go` (`//go:build linux`) + +This adapter is exercised by the DISPLAY-gated smoke test and by hand (`Done When`); unit coverage of accumulation uses the fake `Source` in Task 7. + +- [ ] **Step 1: Implement the Linux adapter** + +`internal/evidence/x11.go`: +```go +//go:build linux + +package evidence + +import ( + "context" + "log" + + "github.com/jezek/xgb/xproto" + "github.com/jezek/xgbutil" + "github.com/jezek/xgbutil/ewmh" + "github.com/jezek/xgbutil/icccm" + "github.com/jezek/xgbutil/xevent" + "github.com/jezek/xgbutil/xprop" + "github.com/jezek/xgbutil/xwindow" +) + +// NewSource returns the real X11 active-window sensor. +func NewSource() Source { return &x11Source{} } + +type x11Source struct{} + +// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW +// changes on the root window, and emits a snapshot immediately plus on every +// change. Any failure degrades to an Unavailable snapshot; it never panics the +// daemon. +func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) { + X, err := xgbutil.NewConn() + if err != nil { + onChange(unavailable("cannot connect to X server: " + err.Error())) + <-ctx.Done() + return + } + defer X.Conn().Close() + + root := X.RootWin() + activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW") + if err != nil { + onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error())) + <-ctx.Done() + return + } + + // Listen for property changes on the root window. + if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil { + onChange(unavailable("cannot listen on root window: " + err.Error())) + <-ctx.Done() + return + } + + emit := func() { onChange(snapshot(X)) } + emit() // immediate current window + + xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) { + if ev.Atom == activeAtom { + emit() + } + }).Connect(X, root) + + // Run the event loop until ctx is cancelled. + go func() { + <-ctx.Done() + xevent.Quit(X) + }() + xevent.Main(X) +} + +func snapshot(X *xgbutil.XUtil) WindowSnapshot { + active, err := ewmh.ActiveWindowGet(X) + if err != nil || active == 0 { + return unavailable("no active window") + } + title, err := ewmh.WmNameGet(X, active) + if err != nil || title == "" { + // Fall back to ICCCM WM_NAME. + if t, e := icccm.WmNameGet(X, active); e == nil { + title = t + } + } + var class string + if wmClass, err := icccm.WmClassGet(X, active); err == nil && wmClass != nil { + class = wmClass.Class + } + return WindowSnapshot{ + Title: title, + Class: class, + Health: EvidenceHealth{Available: true}, + } +} + +func unavailable(reason string) WindowSnapshot { + log.Printf("evidence: %s", reason) + return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}} +} +``` + +- [ ] **Step 2: Implement the non-Linux fallback** + +`internal/evidence/source_other.go`: +```go +//go:build !linux + +package evidence + +import "context" + +// NewSource returns a sensor that reports evidence permanently unavailable on +// platforms without the X11 adapter. +func NewSource() Source { return noopSource{} } + +type noopSource struct{} + +func (noopSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) { + onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no active-window sensor on this platform"}}) + <-ctx.Done() +} +``` + +- [ ] **Step 3: Write the DISPLAY-gated smoke test** + +`internal/evidence/x11_integration_test.go`: +```go +//go:build linux + +package evidence + +import ( + "context" + "os" + "testing" + "time" +) + +func TestX11SourceEmitsWhenDisplaySet(t *testing.T) { + if os.Getenv("DISPLAY") == "" { + t.Skip("no DISPLAY; skipping live X11 smoke test") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + got := make(chan WindowSnapshot, 1) + go NewSource().Watch(ctx, func(s WindowSnapshot) { + select { + case got <- s: + default: + } + }) + + select { + case snap := <-got: + // Either a real window or a clean Unavailable — both are valid; we only + // assert the sensor produced an observation without panicking. + t.Logf("first snapshot: %+v", snap) + case <-ctx.Done(): + t.Fatal("x11 source emitted no snapshot within timeout") + } +} +``` + +- [ ] **Step 4: Build and run** + +Run: `go build ./... && go test ./internal/evidence/` +Expected: build succeeds; tests PASS (smoke test runs or skips depending on `DISPLAY`). + +If a symbol under `github.com/jezek/xgbutil/...` does not resolve (the library's API for `WmNameGet`/`WmClassGet`/`ActiveWindowGet` is stable, but verify against the fetched version), check the package with: +`go doc github.com/jezek/xgbutil/ewmh ActiveWindowGet` and adjust the call to match the installed signature. Do not change behavior — only the call shape. + +- [ ] **Step 5: Run go mod tidy and commit** + +Run: `go mod tidy` +```bash +git add internal/evidence/x11.go internal/evidence/source_other.go internal/evidence/x11_integration_test.go go.mod go.sum +git commit -m "M1: X11 active-window adapter + platform fallback" +``` + +--- + +## Task 7: `session.Controller` — clock, stats, RecordWindow, lifecycle, recovery + +**Files:** +- Modify: `internal/session/session.go` +- Test: `internal/session/session_test.go` + +This is the heart of M1. Implement the controller changes, then the tests verifying accumulation, the unavailable bucket, no-accounting-outside-Active, crash replay, and audit-write-at-End. + +- [ ] **Step 1: Rewrite `internal/session/session.go`** + +Replace the file contents with: +```go +// Package session owns the daemon's in-memory state of truth and persists a +// snapshot on every change. Transitions go through the pure statemachine. It +// also owns per-session evidence stats: it accumulates active-window time while +// Active, logs raw focus events, and seals each session into the audit chain. +package session + +import ( + "log" + "path/filepath" + "sort" + "sync" + "time" + + "antidrift/internal/domain" + "antidrift/internal/evidence" + "antidrift/internal/statemachine" + "antidrift/internal/store" + + "github.com/google/uuid" +) + +const ( + unavailableTitle = "(evidence unavailable)" + sessionRetention = 30 * 24 * time.Hour +) + +// bucketKey identifies a time bucket; Title is the scrubbed title. +type bucketKey struct{ Class, Title string } + +// EvidenceStats is the in-memory accounting for the current session only. +type EvidenceStats struct { + SessionID string + StartedUnix int64 + Buckets map[bucketKey]time.Duration + SwitchCount int + Current evidence.WindowSnapshot + lastFocusAt time.Time + lastKey bucketKey + hasLast bool +} + +// Controller holds runtime state and the active commitment behind a mutex. +type Controller struct { + mu sync.Mutex + runtimeState domain.RuntimeState + commitment *domain.Commitment + deadline time.Time + snapshotPath string + auditPath string + sessionsDir string + clock func() time.Time + onChange func() + latestWindow evidence.WindowSnapshot + stats *EvidenceStats + outcomePending string +} + +// CommitmentView is the UI projection of the active commitment. +type CommitmentView struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` + DeadlineUnixSecs int64 `json:"deadline_unix_secs"` +} + +// WindowView / BucketView / EvidenceView are the evidence projection. +type WindowView struct { + Class string `json:"class"` + Title string `json:"title"` +} + +type BucketView struct { + Class string `json:"class"` + Title string `json:"title"` + Seconds int64 `json:"seconds"` +} + +type EvidenceView struct { + Available bool `json:"available"` + Reason string `json:"reason"` + Current WindowView `json:"current"` + SwitchCount int `json:"switch_count"` + Buckets []BucketView `json:"buckets"` +} + +// State is the broadcastable view of the controller. +type State struct { + RuntimeState domain.RuntimeState `json:"runtime_state"` + Commitment *CommitmentView `json:"commitment"` + Evidence *EvidenceView `json:"evidence"` +} + +// New loads any persisted snapshot, prunes stale session logs, and rebuilds +// in-memory stats from the raw log if a live session was interrupted. +func New(snapshotPath string) (*Controller, error) { + s, err := store.Load(snapshotPath) + if err != nil { + return nil, err + } + dir := filepath.Dir(snapshotPath) + c := &Controller{ + runtimeState: s.RuntimeState, + commitment: s.Commitment, + snapshotPath: snapshotPath, + auditPath: filepath.Join(dir, "audit.jsonl"), + sessionsDir: filepath.Join(dir, "sessions"), + clock: time.Now, + outcomePending: s.OutcomePending, + } + if s.DeadlineUnixSecs > 0 { + c.deadline = time.Unix(s.DeadlineUnixSecs, 0) + } + if c.runtimeState == "" { + c.runtimeState = domain.RuntimeLocked + } + _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) + if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { + c.replayStats(s.SessionID) + } + return c, nil +} + +// SetClock overrides the time source (tests only). Call before starting a +// session. +func (c *Controller) SetClock(f func() time.Time) { + c.mu.Lock() + c.clock = f + c.mu.Unlock() +} + +// SetOnChange registers a callback fired after an evidence-driven state change +// (focus updates). It is invoked with the mutex released. +func (c *Controller) SetOnChange(f func()) { + c.mu.Lock() + c.onChange = f + c.mu.Unlock() +} + +func (c *Controller) notify() { + c.mu.Lock() + f := c.onChange + c.mu.Unlock() + if f != nil { + f() + } +} + +// State returns the current broadcastable state. Safe for concurrent use. +func (c *Controller) State() State { + c.mu.Lock() + defer c.mu.Unlock() + return c.stateLocked() +} + +// Deadline returns the active commitment deadline, or the zero time. +func (c *Controller) Deadline() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.deadline +} + +func (c *Controller) stateLocked() State { + st := State{RuntimeState: c.runtimeState} + if c.commitment != nil { + view := &CommitmentView{ + NextAction: c.commitment.NextAction, + SuccessCondition: c.commitment.SuccessCondition, + TimeboxSecs: c.commitment.TimeboxSecs, + } + if !c.deadline.IsZero() { + view.DeadlineUnixSecs = c.deadline.Unix() + } + st.Commitment = view + } + if c.stats != nil { + st.Evidence = &EvidenceView{ + Available: c.stats.Current.Health.Available, + Reason: c.stats.Current.Health.Reason, + Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title}, + SwitchCount: c.stats.SwitchCount, + Buckets: bucketViews(c.stats.Buckets), + } + } + return st +} + +func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { + out := make([]BucketView, 0, len(buckets)) + for k, d := range buckets { + out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds }) + return out +} + +func (c *Controller) persistLocked() error { + snap := store.Snapshot{ + RuntimeState: c.runtimeState, + Commitment: c.commitment, + OutcomePending: c.outcomePending, + } + if !c.deadline.IsZero() { + snap.DeadlineUnixSecs = c.deadline.Unix() + } + if c.stats != nil { + snap.SessionID = c.stats.SessionID + } + return store.Save(c.snapshotPath, snap) +} + +// EnterPlanning moves Locked -> Planning. +func (c *Controller) EnterPlanning() error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning) + if err != nil { + return err + } + c.runtimeState = next + return c.persistLocked() +} + +// StartManualCommitment validates input, activates a new commitment, mints a +// session, seeds evidence stats from the latest window, and moves Planning -> +// Active. +func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + commitment, err := domain.NewManual(nextAction, successCondition, timebox) + if err != nil { + return err + } + commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate) + if err != nil { + return err + } + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted) + if err != nil { + return err + } + now := c.clock() + c.runtimeState = next + c.commitment = &commitment + c.deadline = now.Add(timebox) + c.outcomePending = "" + + sessionID := "session-" + uuid.Must(uuid.NewV7()).String() + c.stats = &EvidenceStats{ + SessionID: sessionID, + StartedUnix: now.Unix(), + Buckets: map[bucketKey]time.Duration{}, + } + seed := c.latestWindow + _ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed)) + c.applyEvent(now, seed) + return c.persistLocked() +} + +// Complete moves Active -> Review with a "completed" outcome. +func (c *Controller) Complete() error { return c.enterReview("completed") } + +// Expire moves Active -> Review with an "expired" outcome (timebox elapsed). +func (c *Controller) Expire() error { return c.enterReview("expired") } + +func (c *Controller) enterReview(outcome string) error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) + if err != nil { + return err + } + if c.commitment != nil { + completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete) + if err != nil { + return err + } + c.commitment.State = completed + } + // Flush the final open segment, then freeze accounting. + if c.stats != nil && c.stats.hasLast { + c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt) + c.stats.hasLast = false + } + c.runtimeState = next + c.outcomePending = outcome + return c.persistLocked() +} + +// End moves Review -> Locked, writes the session summary to the audit chain, +// and clears the commitment and stats. +func (c *Controller) End() error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod) + if err != nil { + return err + } + if c.stats != nil { + if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil { + // State integrity over audit completeness: the transition still + // completes. Surfaced for the operator; no auto-retry in M1. + log.Printf("session: audit append failed: %v", err) + } + } + c.runtimeState = next + c.commitment = nil + c.deadline = time.Time{} + c.stats = nil + c.outcomePending = "" + return c.persistLocked() +} + +func (c *Controller) buildSummaryLocked() store.SessionSummary { + buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets)) + for k, d := range c.stats.Buckets { + buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) + } + sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds }) + outcome := c.outcomePending + if outcome == "" { + outcome = "completed" + } + var na, sc string + if c.commitment != nil { + na, sc = c.commitment.NextAction, c.commitment.SuccessCondition + } + return store.SessionSummary{ + SessionID: c.stats.SessionID, + NextAction: na, + SuccessCond: sc, + Outcome: outcome, + StartedUnix: c.stats.StartedUnix, + EndedUnix: c.clock().Unix(), + SwitchCount: c.stats.SwitchCount, + Buckets: buckets, + } +} + +// RecordWindow ingests one sensor observation. It accumulates time only while +// Active, always tracks the latest window for display, and fires onChange after +// releasing the mutex. +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) + c.mu.Unlock() + c.notify() +} + +// applyEvent advances stats by one observation: it credits the prior segment to +// the prior bucket, counts a context switch on key change, and records the new +// current window. Used by both live tracking and crash replay. Caller holds mu. +func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) { + if c.stats.hasLast { + c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt) + } + newKey := keyFor(snap) + if c.stats.hasLast && newKey != c.stats.lastKey { + c.stats.SwitchCount++ + } + c.stats.lastKey = newKey + c.stats.lastFocusAt = now + c.stats.hasLast = true + c.stats.Current = snap +} + +// replayStats rebuilds in-memory stats from the raw session log after a crash. +func (c *Controller) replayStats(sessionID string) { + events, err := store.ReplaySession(c.sessionsDir, sessionID) + if err != nil || len(events) == 0 { + c.stats = &EvidenceStats{ + SessionID: sessionID, + StartedUnix: c.clock().Unix(), + Buckets: map[bucketKey]time.Duration{}, + } + return + } + c.stats = &EvidenceStats{ + SessionID: sessionID, + StartedUnix: events[0].AtUnixMillis / 1000, + Buckets: map[bucketKey]time.Duration{}, + } + for _, e := range events { + c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e)) + } +} + +func keyFor(snap evidence.WindowSnapshot) bucketKey { + if !snap.Health.Available { + return bucketKey{Class: "", Title: unavailableTitle} + } + return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)} +} + +func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent { + return store.FocusEvent{ + AtUnixMillis: now.UnixMilli(), + Class: snap.Class, + Title: snap.Title, + Available: snap.Health.Available, + Reason: snap.Health.Reason, + } +} + +func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot { + return evidence.WindowSnapshot{ + Title: e.Title, + Class: e.Class, + Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason}, + } +} +``` + +- [ ] **Step 2: Run to verify existing tests still pass** + +Run: `go test ./internal/session/` +Expected: PASS — the M0 happy-path/restore tests are unaffected (they don't set a clock and don't inspect evidence). + +- [ ] **Step 3: Write the M1 stats tests** + +Append to `internal/session/session_test.go` (add `antidrift/internal/evidence` and `antidrift/internal/store` to imports): +```go +// fakeClock returns successive instants on demand. +type fakeClock struct{ now time.Time } + +func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) } +func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } } + +func snap(class, title string) evidence.WindowSnapshot { + return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}} +} + +func bucketSeconds(t *testing.T, st session.State, class, title string) int64 { + t.Helper() + if st.Evidence == nil { + t.Fatalf("expected evidence projection") + } + for _, b := range st.Evidence.Buckets { + if b.Class == class && b.Title == title { + return b.Seconds + } + } + return -1 +} + +func TestAccumulatesBucketsAndSwitches(t *testing.T) { + c, _ := newTestController(t) + clk := &fakeClock{now: time.Unix(1000, 0)} + c.SetClock(clk.fn()) + c.RecordWindow(snap("code", "antidrift")) // latest window before start + + _ = c.EnterPlanning() + _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift + + clk.advance(10 * time.Second) + c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox + clk.advance(20 * time.Second) + c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back + + st := c.State() + if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 { + t.Errorf("code bucket = %d, want 10", got) + } + if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 { + t.Errorf("firefox bucket = %d, want 20", got) + } + if st.Evidence.SwitchCount != 2 { + t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount) + } +} + +func TestNoAccountingOutsideActive(t *testing.T) { + c, _ := newTestController(t) + clk := &fakeClock{now: time.Unix(1000, 0)} + c.SetClock(clk.fn()) + // Locked: RecordWindow must not create stats. + c.RecordWindow(snap("code", "x")) + if c.State().Evidence != nil { + t.Fatalf("no session: evidence should be nil") + } +} + +func TestUnavailableAccruesToReservedBucket(t *testing.T) { + c, _ := newTestController(t) + clk := &fakeClock{now: time.Unix(1000, 0)} + c.SetClock(clk.fn()) + _ = c.EnterPlanning() + _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow + // latestWindow was zero-value (unavailable) at seed time. + clk.advance(5 * time.Second) + c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket + st := c.State() + if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 { + t.Errorf("unavailable bucket = %d, want 5", got) + } +} + +func TestCrashReplayRebuildsStats(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + first, _ := New(path) + clk := &fakeClock{now: time.Unix(1000, 0)} + first.SetClock(clk.fn()) + first.RecordWindow(snap("code", "antidrift")) + _ = first.EnterPlanning() + _ = first.StartManualCommitment("work", "done", 25*time.Minute) + clk.advance(15 * time.Second) + first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch + + // Simulate crash + restart: New replays the raw log. + second, err := New(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + st := second.State() + if st.RuntimeState != domain.RuntimeActive { + t.Fatalf("restored state should be Active, got %s", st.RuntimeState) + } + if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 { + t.Errorf("replayed code bucket = %d, want 15", got) + } + if st.Evidence.SwitchCount != 1 { + t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount) + } +} + +func TestEndWritesAuditSummary(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + c, _ := New(path) + clk := &fakeClock{now: time.Unix(1000, 0)} + c.SetClock(clk.fn()) + c.RecordWindow(snap("code", "antidrift")) + _ = c.EnterPlanning() + _ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute) + clk.advance(30 * time.Second) + c.RecordWindow(snap("firefox", "docs")) + clk.advance(10 * time.Second) + if err := c.Complete(); err != nil { // flush: +10s to firefox/docs + t.Fatalf("complete: %v", err) + } + if err := c.End(); err != nil { + t.Fatalf("end: %v", err) + } + + auditPath := filepath.Join(dir, "audit.jsonl") + if err := store.VerifyChain(auditPath); err != nil { + t.Fatalf("chain should verify: %v", err) + } + // Re-read and assert the single summary's totals. + c2, _ := New(path) // not strictly needed; read raw instead + _ = c2 +} +``` + +Note: `TestEndWritesAuditSummary` verifies the chain is valid after `End`. To assert bucket contents, the simplest check is `VerifyChain` passing plus a non-empty file; deeper field assertions are already covered by `store/audit_test.go`. Keep the test focused on "End extends a verifiable chain." + +- [ ] **Step 4: Run to verify pass** + +Run: `go test ./internal/session/` +Expected: PASS (all M0 + M1 session tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/session/session.go internal/session/session_test.go +git commit -m "M1: controller evidence stats, accumulation, replay, audit-at-End" +``` + +--- + +## Task 8: `web` — wire onChange, switch expiry to Expire + +**Files:** +- Modify: `internal/web/web.go` +- Test: `internal/web/web_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/web/web_test.go` (add `antidrift/internal/evidence` to imports): +```go +func TestActiveStatePayloadCarriesEvidence(t *testing.T) { + s := newTestServer(t) + r := s.Router() + _ = post(t, r, "/planning", "") + body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` + if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { + t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) + } + // A focus update should appear in the serialized state. + s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}}) + + js := s.stateJSON() + if !strings.Contains(js, `"evidence"`) { + t.Fatalf("payload missing evidence object: %s", js) + } + if !strings.Contains(js, `"class":"code"`) { + t.Fatalf("payload missing current window: %s", js) + } +} + +func TestLockedStateHasNullEvidence(t *testing.T) { + s := newTestServer(t) + js := s.stateJSON() + if !strings.Contains(js, `"evidence":null`) { + t.Fatalf("locked payload should have null evidence: %s", js) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/web/ -run 'Evidence|NullEvidence'` +Expected: the `RecordWindow` call won't compile until imports are added, then the null-evidence assertion passes already (State.Evidence is nil for Locked) but the evidence-present test confirms wiring. If both compile and pass without web.go changes, still apply Step 3 (onChange + Expire) — it is required for live SSE and correct outcome tracking. + +- [ ] **Step 3: Update `web.go`** + +In `NewServer`, register the broadcast callback so focus updates fan out over SSE: +```go +func NewServer(ctrl *session.Controller) *Server { + s := &Server{ctrl: ctrl, bcast: NewBroadcaster()} + ctrl.SetOnChange(s.broadcast) + return s +} +``` + +In `armExpiry`, the timer fires an *expiry*, not a user completion: +```go + s.timer = time.AfterFunc(d, func() { + if err := s.ctrl.Expire(); err == nil { + s.broadcast() + } + }) +``` + +In `Init`, a restored-but-past-deadline session also expires: +```go + if err := s.ctrl.Expire(); err == nil { + s.broadcast() + } +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `go test ./internal/web/` +Expected: PASS (all web tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/web.go internal/web/web_test.go +git commit -m "M1: broadcast focus updates, expiry uses Expire outcome" +``` + +--- + +## Task 9: Wire the sensor in `main.go` + +**Files:** +- Modify: `cmd/antidriftd/main.go` + +- [ ] **Step 1: Construct the source and start watching** + +Edit `cmd/antidriftd/main.go`. Add imports `"context"` and `"antidrift/internal/evidence"`. After `srv.Init()` and before `go openBrowser(...)`: +```go + src := evidence.NewSource() + go src.Watch(context.Background(), ctrl.RecordWindow) +``` + +The goroutine runs for the daemon's lifetime; `ctrl.RecordWindow` is safe for concurrent use and only accounts time while Active. + +- [ ] **Step 2: Build** + +Run: `go build ./...` +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add cmd/antidriftd/main.go +git commit -m "M1: start active-window sensor wired to the controller" +``` + +--- + +## Task 10: Surface evidence in the browser UI + +**Files:** +- Modify: `internal/web/static/index.html` + +The page stays a pure renderer. Add an evidence block to the Active view and a summary to the Review view. No design polish (that is M4). + +- [ ] **Step 1: Add a small evidence renderer and styles** + +In the `