Files
antidrift/docs/superpowers/plans/2026-05-31-m1-evidence-audit.md
T
2026-05-31 12:31:35 -04:00

1745 lines
54 KiB
Markdown

# 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/<sessionID>.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 `<style>` block, add:
```css
.ev { margin-top: 18px; border-top: 1px solid #272b38; padding-top: 14px; }
.ev .now { font-size: 14px; color: #cdd2e0; }
.ev .health-ok { color: #5fd08a; }
.ev .health-bad { color: #e0b15f; }
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: #9aa0b4; }
.buckets li { display: flex; justify-content: space-between; padding: 2px 0; font-variant-numeric: tabular-nums; }
.switches { font-size: 13px; color: #7a8095; margin-top: 8px; }
```
Add this helper in the `<script>` block (above `render`):
```javascript
function evidenceBlock(ev) {
if (!ev) return '';
const health = ev.available
? `<span class="health-ok">tracking</span>`
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
const now = ev.current && (ev.current.class || ev.current.title)
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
const rows = (ev.buckets || []).map(b =>
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
return `<div class="ev">
<div class="now">Now: ${now} &nbsp; ${health}</div>
<ul class="buckets">${rows}</ul>
<div class="switches">Context switches: ${ev.switch_count || 0}</div>
</div>`;
}
```
- [ ] **Step 2: Render it in the Active and Review views**
In `render`, replace the Active branch body to append the evidence block:
```javascript
} else if (rs === 'active') {
const c = state.commitment || {};
view.innerHTML = `<span class="pill">Active</span>
<div class="timer" id="t">--:--</div>
<div class="action">${c.next_action || ''}</div>
<p class="meta">Done when: ${c.success_condition || ''}</p>
${evidenceBlock(state.evidence)}
<button id="done">Complete</button>`;
document.getElementById('done').onclick = () => post('/complete');
const t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick();
countdownTimer = setInterval(tick, 250);
} else if (rs === 'review') {
const c = state.commitment || {};
view.innerHTML = `<span class="pill">Review</span>
<p class="action">Session ended</p>
<p class="meta">${c.next_action || ''}</p>
${evidenceBlock(state.evidence)}
<button id="end">End</button>`;
document.getElementById('end').onclick = () => post('/end');
} else {
```
- [ ] **Step 3: Verify the build embeds the updated asset**
Run: `go build ./... && go test ./...`
Expected: all tests PASS; the embedded `static/index.html` is the updated file.
- [ ] **Step 4: Commit**
```bash
git add internal/web/static/index.html
git commit -m "M1: render evidence (current window, buckets, switches) in UI"
```
---
## Task 11: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Run the whole suite**
Run: `go test ./...`
Expected: PASS across all packages.
- [ ] **Step 2: Vet and build**
Run: `go vet ./... && go build ./...`
Expected: no warnings, clean build.
- [ ] **Step 3: Manual smoke (the spec's "Done When")**
Run: `go run ./cmd/antidriftd`, then in the browser at http://localhost:7777:
- Start a commitment; switch between a couple of windows.
- Confirm the Active view updates live with current window, per-app time, and switch count.
- Let it complete (or expire) into Review; confirm the summary shows.
- End it; confirm `~/.antidrift/audit.jsonl` gained one line.
Verify the chain from a tiny scratch check (optional): the `store.VerifyChain` path is already covered by tests; the manual step confirms a real line is written.
- [ ] **Step 4: Update the README status line**
In `README.md`, update the Status section to note M1 is done:
```markdown
## Status
M1 (evidence & audit): active-window tracking, two-tier evidence store
(disposable per-session raw log + permanent hash-chained session summaries),
and live SSE updates. AI integration and drift judgment arrive in later
milestones (see the roadmap in
`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`).
```
- [ ] **Step 5: Commit**
```bash
git add README.md
git commit -m "M1: mark evidence & audit milestone done in README"
```
---
## Self-review notes (already reconciled)
- **Spec coverage:** `evidence` types/Source/ScrubTitle (T2), X11 adapter + fake (T6/T2), `store/audit` chain (T3), `store/evidence_log` + prune (T4), snapshot fields (T5), controller clock/stats/RecordWindow/lifecycle/replay/audit-at-End/evidence projection (T7), web onChange + Expire + payload (T8), main wiring (T9), UI (T10), retention/Done-When (T11). All spec sections map to a task.
- **Type consistency:** `SessionSummary`/`BucketTotal`/`FocusEvent` field names are identical across `store`, `session`, and tests; `EvidenceView`/`BucketView`/`WindowView` JSON tags match the spec payload (`available`, `reason`, `current.class/title`, `switch_count`, `buckets[].seconds`).
- **Deliberate deviations (see "Design decisions locked in"):** onChange fired only by `RecordWindow` (deadlock-safe; HTTP/timer paths broadcast as before); a seed FocusEvent makes the raw log self-sufficient for replay; expired sessions reuse the `statemachine.Complete` commitment transition (only the audit `Outcome` differs).