Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
onChangefires only fromRecordWindow. It is the one state-change path that originates outside an HTTP handler (the evidence goroutine). HTTP user actions continue to broadcast via the existingweb.respond(); the expiry timer keeps its explicitbroadcast()afterExpire(). This avoids a lock-reentry deadlock (onChange → broadcast → ctrl.State() → c.mu).RecordWindowalways releases the mutex before callingnotify().- A seed
FocusEventis appended at session start.StartManualCommitmentwrites oneFocusEventfor 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 privateapplyEventroutine serves both live tracking and replay. - The injectable clock (
c.clock func() time.Time, defaulttime.Now) is used for every timestamp and duration in the controller (deadline,StartedUnix, segment accumulation,EndedUnix). Tests set it viaSetClock. - Outcome ("completed" vs "expired") is tracked by
c.outcomePending, set when entering Review: userComplete()→"completed", timer/startupExpire()→"expired". The underlying commitment transition is identical for both (statemachine.Complete); only the auditOutcomefield differs. Changing the commitment state machine is out of M1 scope. - Store paths are derived from the snapshot path's directory:
audit.jsonlandsessions/live besidestate.json.New(path)'s signature is unchanged; tests get isolation for free viat.TempDir().
File Structure
- Create
internal/evidence/evidence.go—EvidenceHealth,WindowSnapshot,Source,ScrubTitle. - Create
internal/evidence/evidence_test.go—ScrubTitletable tests + a fakeSource. - 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—SnapshotgainsSessionID,OutcomePending. - Modify
internal/session/session.go— clock, stats,RecordWindow, lifecycle hooks,SetOnChange, recovery, evidence projection inState. - Modify
internal/session/session_test.go— accumulation / replay / audit-at-End tests. - Modify
internal/web/web.go— registeronChange, switch timer + Init toExpire(). - 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— constructevidence.NewSource(), startWatchgoroutine wired toctrl.RecordWindow. - Modify
go.mod/go.sum— addgithub.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} ${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.jsonlgained 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:
evidencetypes/Source/ScrubTitle (T2), X11 adapter + fake (T6/T2),store/auditchain (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/FocusEventfield names are identical acrossstore,session, and tests;EvidenceView/BucketView/WindowViewJSON 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 thestatemachine.Completecommitment transition (only the auditOutcomediffers).