# 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 `