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