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

54 KiB

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 WindowSnapshots 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.goEvidenceHealth, WindowSnapshot, Source, ScrubTitle.
  • Create internal/evidence/evidence_test.goScrubTitle table tests + a fake Source.
  • Create internal/evidence/x11.gox11Source + NewSource() (//go:build linux).
  • Create internal/evidence/source_other.goNewSource() fallback (//go:build !linux).
  • Create internal/evidence/x11_integration_test.go — DISPLAY-gated live smoke test (//go:build linux).
  • Create internal/store/audit.goBucketTotal, SessionSummary, AppendSession, VerifyChain.
  • Create internal/store/audit_test.go.
  • Create internal/store/evidence_log.goFocusEvent, AppendFocus, ReplaySession, PruneOlderThan.
  • Create internal/store/evidence_log_test.go.
  • Modify internal/store/store.goSnapshot 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:

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
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:

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:

// 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
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:

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:

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
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:

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:

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
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:

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:

// 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
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: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: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: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

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:

// 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):

// 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
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):

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:

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:

	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:

	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
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(...):

	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
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:

  .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):

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:

  } 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
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:

## 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
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).