From 0eb30ca93d70cb26b451305df5efb227642f7203 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 11:31:17 -0400 Subject: [PATCH] Add M0 walking-skeleton implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-05-31-m0-walking-skeleton.md | 1793 +++++++++++++++++ 1 file changed, 1793 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md diff --git a/docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md b/docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md new file mode 100644 index 0000000..6d1bac1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md @@ -0,0 +1,1793 @@ +# M0 Walking Skeleton 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:** Stand up the Go `antidriftd` daemon that serves a local web UI and drives one manual commitment through Locked → Planning → Active → Review → Locked, persisting a snapshot that survives restart. + +**Architecture:** A single Go process holds runtime state in memory behind a mutex (`session.Controller`), persists a JSON snapshot on every change (`store`), and serves a vanilla-JS browser UI over Gin with a Server-Sent Events (SSE) stream for live state. Domain types and the pure state machine are direct ports of the existing Rust. Timebox expiry is server-authoritative via a `time.Timer`. + +**Tech Stack:** Go 1.26, Gin (`github.com/gin-gonic/gin`), `github.com/google/uuid` (UUIDv7), Go standard library (`net/http`, `encoding/json`, `os`, `sync`, `time`, `embed`), `go test`. + +--- + +## Scope + +Implements M0 from `docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md`. + +Excludes (later milestones): AI, active-window tracking, hash-chained audit log, allowed-context matching, violation friction, transition/admin-override flows beyond the M0 subset, visual polish. + +## File Structure + +- `go.mod` — module `antidrift`. +- `legacy/` — the existing Rust sources, moved aside for reference. +- `cmd/antidriftd/main.go` — entrypoint: load snapshot, build server, re-arm/expire timer, open browser, serve. +- `internal/domain/domain.go` — `Commitment`, `PolicySnapshot`, `AllowedContext`, state/enforcement enums, validation. (`domain_test.go`) +- `internal/statemachine/statemachine.go` — pure `TransitionRuntime` / `TransitionCommitment`. (`statemachine_test.go`) +- `internal/store/store.go` — `Snapshot`, `Load`, `Save`, `DefaultPath`. (`store_test.go`) +- `internal/session/session.go` — `Controller` (in-memory state + persistence), `State` projection. (`session_test.go`) +- `internal/web/broadcaster.go` — SSE fan-out `Broadcaster`. (`broadcaster_test.go`) +- `internal/web/web.go` — `Server`, routes, handlers, expiry timer. (`web_test.go`) +- `internal/web/static/index.html` — single-page UI (HTML + CSS + vanilla JS). + +--- + +## Task 1: Repository And Module Setup + +**Files:** +- Create: `go.mod` +- Create: `cmd/antidriftd/main.go` +- Move: `Cargo.toml`, `Cargo.lock`, `src/`, `antidrift.desktop` → `legacy/` +- Modify: `.gitignore` + +- [ ] **Step 1: Move the Rust sources into `legacy/`** + +Run: + +```bash +mkdir -p legacy +git mv Cargo.toml Cargo.lock src antidrift.desktop legacy/ +rm -rf target +``` + +Expected: `legacy/` now contains `Cargo.toml`, `Cargo.lock`, `src/`, `antidrift.desktop`. `docs/`, `README.md`, `LICENSE` stay at the root. + +- [ ] **Step 2: Initialize the Go module** + +Run: + +```bash +go mod init antidrift +``` + +Expected: `go.mod` created with `module antidrift` and a `go 1.26` line. + +- [ ] **Step 3: Add Go entries to `.gitignore`** + +Append to `.gitignore`: + +```gitignore +# Go +/antidriftd +*.test +``` + +- [ ] **Step 4: Create a minimal entrypoint so the module builds** + +Create `cmd/antidriftd/main.go`: + +```go +package main + +import "fmt" + +func main() { + fmt.Println("antidriftd") +} +``` + +- [ ] **Step 5: Verify the module builds** + +Run: + +```bash +go build ./... +``` + +Expected: builds with no output. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Set up Go module and move Rust to legacy" +``` + +## Task 2: Domain Types + +**Files:** +- Create: `internal/domain/domain.go` +- Test: `internal/domain/domain_test.go` + +- [ ] **Step 1: Add the uuid dependency** + +Run: + +```bash +go get github.com/google/uuid@latest +``` + +Expected: `go.mod` / `go.sum` updated with `github.com/google/uuid`. + +- [ ] **Step 2: Write the failing domain tests** + +Create `internal/domain/domain_test.go`: + +```go +package domain + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestNewManualRejectsEmptyNextAction(t *testing.T) { + _, err := NewManual("", "tests pass", 25*time.Minute) + if err != ErrMissingNextAction { + t.Fatalf("want ErrMissingNextAction, got %v", err) + } +} + +func TestNewManualRejectsEmptySuccessCondition(t *testing.T) { + _, err := NewManual("Refactor state", " ", 25*time.Minute) + if err != ErrMissingSuccessCondition { + t.Fatalf("want ErrMissingSuccessCondition, got %v", err) + } +} + +func TestNewManualRejectsZeroTimebox(t *testing.T) { + _, err := NewManual("Refactor state", "tests pass", 0) + if err != ErrMissingTimebox { + t.Fatalf("want ErrMissingTimebox, got %v", err) + } +} + +func TestNewManualPopulatesDraftCommitment(t *testing.T) { + c, err := NewManual(" Port domain ", "domain tests pass", 25*time.Minute) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.NextAction != "Port domain" { + t.Errorf("next action not trimmed: %q", c.NextAction) + } + if c.SuccessCondition != "domain tests pass" { + t.Errorf("success condition wrong: %q", c.SuccessCondition) + } + if c.TimeboxSecs != 1500 { + t.Errorf("timebox secs wrong: %d", c.TimeboxSecs) + } + if c.State != CommitmentDraft { + t.Errorf("state should be draft, got %s", c.State) + } + if c.Source != SourceManual { + t.Errorf("source should be manual, got %s", c.Source) + } + if !strings.HasPrefix(c.ID, "commitment-") { + t.Errorf("id should be prefixed, got %s", c.ID) + } +} + +func TestCommitmentSerializesSnakeCaseEnums(t *testing.T) { + c, _ := NewManual("Port domain", "domain tests pass", 25*time.Minute) + data, err := json.Marshal(c) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + s := string(data) + if !strings.Contains(s, `"source":"manual"`) { + t.Errorf("missing snake_case source in %s", s) + } + if !strings.Contains(s, `"state":"draft"`) { + t.Errorf("missing snake_case state in %s", s) + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: + +```bash +go test ./internal/domain/ +``` + +Expected: compile failure — `NewManual`, `ErrMissing*`, and the constants are undefined. + +- [ ] **Step 4: Implement the domain types** + +Create `internal/domain/domain.go`: + +```go +// Package domain holds the core commitment types and validation, ported from +// the original Rust implementation. These are pure data types with no I/O. +package domain + +import ( + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +const PolicySchemaVersion = 1 + +type CommitmentSource string + +const ( + SourceManual CommitmentSource = "manual" + SourcePlanner CommitmentSource = "planner" + SourceRecurring CommitmentSource = "recurring" + SourceRecovery CommitmentSource = "recovery" + SourceTemplate CommitmentSource = "template" +) + +type CommitmentState string + +const ( + CommitmentDraft CommitmentState = "draft" + CommitmentActive CommitmentState = "active" + CommitmentPaused CommitmentState = "paused" + CommitmentCompleted CommitmentState = "completed" + CommitmentAbandoned CommitmentState = "abandoned" + CommitmentViolated CommitmentState = "violated" +) + +type RuntimeState string + +const ( + RuntimeLocked RuntimeState = "locked" + RuntimePlanning RuntimeState = "planning" + RuntimeActive RuntimeState = "active" + RuntimeTransition RuntimeState = "transition" + RuntimeReview RuntimeState = "review" + RuntimeAdminOverride RuntimeState = "admin_override" +) + +type EnforcementLevel string + +const ( + EnforcementObserve EnforcementLevel = "observe" + EnforcementWarn EnforcementLevel = "warn" + EnforcementBlock EnforcementLevel = "block" + EnforcementLocked EnforcementLevel = "locked" +) + +// AllowedContext is carried by PolicySnapshot. Matching logic arrives in a +// later milestone; M0 only stores it. +type AllowedContext struct { + WindowClasses []string `json:"window_classes"` + WindowTitleSubstrings []string `json:"window_title_substrings"` + Domains []string `json:"domains"` + Repos []string `json:"repos"` + Commands []string `json:"commands"` +} + +type Commitment struct { + ID string `json:"id"` + CreatedAtUnixSecs int64 `json:"created_at_unix_secs"` + Source CommitmentSource `json:"source"` + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` + State CommitmentState `json:"state"` +} + +type PolicySnapshot struct { + ID string `json:"id"` + CommitmentID string `json:"commitment_id"` + SchemaVersion int `json:"schema_version"` + CreatedAtUnixSecs int64 `json:"created_at_unix_secs"` + RuntimeState RuntimeState `json:"runtime_state"` + EnforcementLevel EnforcementLevel `json:"enforcement_level"` + AllowedContext AllowedContext `json:"allowed_context"` +} + +var ( + ErrMissingNextAction = errors.New("next action is required") + ErrMissingSuccessCondition = errors.New("success condition is required") + ErrMissingTimebox = errors.New("timebox must be nonzero") +) + +// NewManual builds a validated draft commitment from user input. +func NewManual(nextAction, successCondition string, timebox time.Duration) (Commitment, error) { + nextAction = strings.TrimSpace(nextAction) + successCondition = strings.TrimSpace(successCondition) + if nextAction == "" { + return Commitment{}, ErrMissingNextAction + } + if successCondition == "" { + return Commitment{}, ErrMissingSuccessCondition + } + if timebox <= 0 { + return Commitment{}, ErrMissingTimebox + } + return Commitment{ + ID: "commitment-" + uuid.Must(uuid.NewV7()).String(), + CreatedAtUnixSecs: time.Now().Unix(), + Source: SourceManual, + NextAction: nextAction, + SuccessCondition: successCondition, + TimeboxSecs: int64(timebox.Seconds()), + State: CommitmentDraft, + }, nil +} + +// ForRuntime builds a minimal accepted policy snapshot for a commitment. +func ForRuntime(commitmentID string, runtime RuntimeState, level EnforcementLevel) PolicySnapshot { + return PolicySnapshot{ + ID: "policy-" + uuid.Must(uuid.NewV7()).String(), + CommitmentID: commitmentID, + SchemaVersion: PolicySchemaVersion, + CreatedAtUnixSecs: time.Now().Unix(), + RuntimeState: runtime, + EnforcementLevel: level, + AllowedContext: AllowedContext{}, + } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: + +```bash +go test ./internal/domain/ +``` + +Expected: PASS (all five tests). + +- [ ] **Step 6: Commit** + +```bash +git add internal/domain/ go.mod go.sum +git commit -m "Add domain types and validation" +``` + +## Task 3: State Machine + +**Files:** +- Create: `internal/statemachine/statemachine.go` +- Test: `internal/statemachine/statemachine_test.go` + +- [ ] **Step 1: Write the failing state-machine tests** + +Create `internal/statemachine/statemachine_test.go`: + +```go +package statemachine + +import ( + "testing" + + "antidrift/internal/domain" +) + +func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) { + got, err := TransitionRuntime(domain.RuntimePlanning, ActivateAccepted) + if err != nil || got != domain.RuntimeActive { + t.Fatalf("accepted: got %s err %v", got, err) + } + _, err = TransitionRuntime(domain.RuntimePlanning, ActivateRejected) + if err == nil { + t.Fatalf("rejected policy should error") + } +} + +func TestActiveCanCompleteForReview(t *testing.T) { + got, err := TransitionRuntime(domain.RuntimeActive, CompleteForReview) + if err != nil || got != domain.RuntimeReview { + t.Fatalf("got %s err %v", got, err) + } +} + +func TestReviewEndsToLocked(t *testing.T) { + got, err := TransitionRuntime(domain.RuntimeReview, EndWorkPeriod) + if err != nil || got != domain.RuntimeLocked { + t.Fatalf("got %s err %v", got, err) + } +} + +func TestLockedCannotBecomeActiveDirectly(t *testing.T) { + _, err := TransitionRuntime(domain.RuntimeLocked, ActivateAccepted) + if err == nil { + t.Fatalf("locked->active must be illegal") + } +} + +func TestCommitmentDraftActivatesAndViolatedRecovers(t *testing.T) { + got, err := TransitionCommitment(domain.CommitmentDraft, CommitmentActivate) + if err != nil || got != domain.CommitmentActive { + t.Fatalf("draft->active: got %s err %v", got, err) + } + got, err = TransitionCommitment(domain.CommitmentViolated, RecoverExplicitly) + if err != nil || got != domain.CommitmentActive { + t.Fatalf("violated->active: got %s err %v", got, err) + } + _, err = TransitionCommitment(domain.CommitmentViolated, ReturnFromPause) + if err == nil { + t.Fatalf("violated->returnFromPause must be illegal") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +go test ./internal/statemachine/ +``` + +Expected: compile failure — transition functions and action constants are undefined. + +- [ ] **Step 3: Implement the state machine** + +Create `internal/statemachine/statemachine.go`: + +```go +// Package statemachine holds the pure runtime and commitment transition +// functions ported from the Rust implementation. No I/O, no shared state. +package statemachine + +import ( + "fmt" + + "antidrift/internal/domain" +) + +// RuntimeAction enumerates runtime transitions. Activate's policy acceptance +// and admin-override exit targets from the Rust enum are flattened into +// distinct actions so the action type stays a simple value. +type RuntimeAction string + +const ( + EnterPlanning RuntimeAction = "enter_planning" + CancelOrTimeout RuntimeAction = "cancel_or_timeout" + ActivateAccepted RuntimeAction = "activate_accepted" + ActivateRejected RuntimeAction = "activate_rejected" + StartTransition RuntimeAction = "start_transition" + CompleteForReview RuntimeAction = "complete_for_review" + SevereViolation RuntimeAction = "severe_violation" + ReturnToActive RuntimeAction = "return_to_active" + SwitchTask RuntimeAction = "switch_task" + EndTransitionForReview RuntimeAction = "end_transition_for_review" + ContinuePlanning RuntimeAction = "continue_planning" + EndWorkPeriod RuntimeAction = "end_work_period" + EnterAdminOverride RuntimeAction = "enter_admin_override" + ExitAdminOverrideToLocked RuntimeAction = "exit_admin_override_to_locked" + ExitAdminOverrideToPlanning RuntimeAction = "exit_admin_override_to_planning" +) + +type CommitmentAction string + +const ( + CommitmentActivate CommitmentAction = "activate" + PauseForTransition CommitmentAction = "pause_for_transition" + ReturnFromPause CommitmentAction = "return_from_pause" + Complete CommitmentAction = "complete" + Abandon CommitmentAction = "abandon" + Violate CommitmentAction = "violate" + RecoverExplicitly CommitmentAction = "recover_explicitly" +) + +// IllegalTransitionError reports a rejected transition. +type IllegalTransitionError struct { + From string + Action string +} + +func (e IllegalTransitionError) Error() string { + return fmt.Sprintf("illegal transition from %s via %s", e.From, e.Action) +} + +// PolicyRejectedError reports an activation attempted with an unaccepted policy. +type PolicyRejectedError struct{} + +func (PolicyRejectedError) Error() string { return "policy was rejected" } + +func TransitionRuntime(current domain.RuntimeState, action RuntimeAction) (domain.RuntimeState, error) { + type key struct { + s domain.RuntimeState + a RuntimeAction + } + table := map[key]domain.RuntimeState{ + {domain.RuntimeLocked, EnterPlanning}: domain.RuntimePlanning, + {domain.RuntimePlanning, ActivateAccepted}: domain.RuntimeActive, + {domain.RuntimePlanning, CancelOrTimeout}: domain.RuntimeLocked, + {domain.RuntimeActive, StartTransition}: domain.RuntimeTransition, + {domain.RuntimeActive, CompleteForReview}: domain.RuntimeReview, + {domain.RuntimeActive, SevereViolation}: domain.RuntimeLocked, + {domain.RuntimeTransition, ReturnToActive}: domain.RuntimeActive, + {domain.RuntimeTransition, SwitchTask}: domain.RuntimePlanning, + {domain.RuntimeTransition, EndTransitionForReview}: domain.RuntimeReview, + {domain.RuntimeTransition, CancelOrTimeout}: domain.RuntimeLocked, + {domain.RuntimeReview, ContinuePlanning}: domain.RuntimePlanning, + {domain.RuntimeReview, EndWorkPeriod}: domain.RuntimeLocked, + {domain.RuntimeAdminOverride, ExitAdminOverrideToLocked}: domain.RuntimeLocked, + {domain.RuntimeAdminOverride, ExitAdminOverrideToPlanning}: domain.RuntimePlanning, + } + + if action == ActivateRejected && current == domain.RuntimePlanning { + return current, PolicyRejectedError{} + } + if action == EnterAdminOverride { + return domain.RuntimeAdminOverride, nil + } + if next, ok := table[key{current, action}]; ok { + return next, nil + } + return current, IllegalTransitionError{From: string(current), Action: string(action)} +} + +func TransitionCommitment(current domain.CommitmentState, action CommitmentAction) (domain.CommitmentState, error) { + type key struct { + s domain.CommitmentState + a CommitmentAction + } + table := map[key]domain.CommitmentState{ + {domain.CommitmentDraft, CommitmentActivate}: domain.CommitmentActive, + {domain.CommitmentActive, PauseForTransition}: domain.CommitmentPaused, + {domain.CommitmentPaused, ReturnFromPause}: domain.CommitmentActive, + {domain.CommitmentActive, Complete}: domain.CommitmentCompleted, + {domain.CommitmentActive, Abandon}: domain.CommitmentAbandoned, + {domain.CommitmentActive, Violate}: domain.CommitmentViolated, + {domain.CommitmentPaused, Abandon}: domain.CommitmentAbandoned, + {domain.CommitmentViolated, RecoverExplicitly}: domain.CommitmentActive, + {domain.CommitmentViolated, Abandon}: domain.CommitmentAbandoned, + } + if next, ok := table[key{current, action}]; ok { + return next, nil + } + return current, IllegalTransitionError{From: string(current), Action: string(action)} +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +go test ./internal/statemachine/ +``` + +Expected: PASS (all transition tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/statemachine/ +git commit -m "Add runtime and commitment state machines" +``` + +## Task 4: Snapshot Store + +**Files:** +- Create: `internal/store/store.go` +- Test: `internal/store/store_test.go` + +- [ ] **Step 1: Write the failing store tests** + +Create `internal/store/store_test.go`: + +```go +package store + +import ( + "path/filepath" + "testing" + + "antidrift/internal/domain" +) + +func TestLoadMissingFileReturnsLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + s, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.RuntimeState != domain.RuntimeLocked { + t.Errorf("missing file should be Locked, got %s", s.RuntimeState) + } + if s.Commitment != nil { + t.Errorf("missing file should have nil commitment") + } +} + +func TestSaveThenLoadRoundTrips(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "state.json") + c, _ := domain.NewManual("Port store", "store tests pass", 1500) + c.State = domain.CommitmentActive + want := Snapshot{ + RuntimeState: domain.RuntimeActive, + Commitment: &c, + DeadlineUnixSecs: 1748725200, + } + if err := Save(path, want); err != nil { + t.Fatalf("save failed: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("load failed: %v", err) + } + if got.RuntimeState != want.RuntimeState { + t.Errorf("runtime state: got %s want %s", got.RuntimeState, want.RuntimeState) + } + if got.Commitment == nil || got.Commitment.NextAction != "Port store" { + t.Errorf("commitment did not round-trip: %+v", got.Commitment) + } + if got.DeadlineUnixSecs != want.DeadlineUnixSecs { + t.Errorf("deadline: got %d want %d", got.DeadlineUnixSecs, want.DeadlineUnixSecs) + } +} +``` + +Note: `domain.NewManual` takes a `time.Duration`; `1500` here is 1500 nanoseconds but the value is irrelevant to the store test because the test overrides `State` and only reads `NextAction`. To avoid a lint/type concern, call it as `domain.NewManual("Port store", "store tests pass", 25*60*1e9)` is wrong; instead import `time` and pass `25*time.Minute`. Use this exact call in the test: `domain.NewManual("Port store", "store tests pass", 25*time.Minute)` and add `"time"` to the imports. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +go test ./internal/store/ +``` + +Expected: compile failure — `Snapshot`, `Load`, `Save` undefined. + +- [ ] **Step 3: Implement the store** + +Create `internal/store/store.go`: + +```go +// Package store persists and restores the daemon's current state as a single +// JSON snapshot. It is the M0 source of durability; the hash-chained audit log +// arrives in a later milestone. +package store + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "antidrift/internal/domain" +) + +// 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"` +} + +// DefaultPath returns ~/.antidrift/state.json. +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".antidrift", "state.json"), nil +} + +// Load reads a snapshot. A missing file yields a default Locked snapshot. +func Load(path string) (Snapshot, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return Snapshot{RuntimeState: domain.RuntimeLocked}, nil + } + if err != nil { + return Snapshot{}, err + } + var s Snapshot + if err := json.Unmarshal(data, &s); err != nil { + return Snapshot{}, err + } + return s, nil +} + +// Save writes a snapshot atomically (temp file + rename), creating the +// directory if needed. +func Save(path string, s Snapshot) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +go test ./internal/store/ +``` + +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/ +git commit -m "Add snapshot store" +``` + +## Task 5: Session Controller + +**Files:** +- Create: `internal/session/session.go` +- Test: `internal/session/session_test.go` + +- [ ] **Step 1: Write the failing session tests** + +Create `internal/session/session_test.go`: + +```go +package session + +import ( + "path/filepath" + "testing" + "time" + + "antidrift/internal/domain" +) + +func newTestController(t *testing.T) (*Controller, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "state.json") + c, err := New(path) + if err != nil { + t.Fatalf("new controller: %v", err) + } + return c, path +} + +func TestHappyPathDrivesStates(t *testing.T) { + c, _ := newTestController(t) + if c.State().RuntimeState != domain.RuntimeLocked { + t.Fatalf("should start Locked") + } + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil { + t.Fatalf("start commitment: %v", err) + } + st := c.State() + if st.RuntimeState != domain.RuntimeActive { + t.Fatalf("should be Active, got %s", st.RuntimeState) + } + if st.Commitment == nil || st.Commitment.NextAction != "Port session" { + t.Fatalf("active commitment missing: %+v", st.Commitment) + } + if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() { + t.Fatalf("deadline should be in the future") + } + if err := c.Complete(); err != nil { + t.Fatalf("complete: %v", err) + } + if c.State().RuntimeState != domain.RuntimeReview { + t.Fatalf("should be Review after complete") + } + if err := c.End(); err != nil { + t.Fatalf("end: %v", err) + } + if c.State().RuntimeState != domain.RuntimeLocked { + t.Fatalf("should be Locked after end") + } +} + +func TestStartCommitmentRejectsInvalidInput(t *testing.T) { + c, _ := newTestController(t) + _ = c.EnterPlanning() + if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil { + t.Fatalf("empty next action should error") + } + if c.State().RuntimeState != domain.RuntimePlanning { + t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState) + } +} + +func TestStateRestoresFromSnapshot(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + first, err := New(path) + if err != nil { + t.Fatalf("new: %v", err) + } + _ = first.EnterPlanning() + _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute) + + 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 st.Commitment == nil || st.Commitment.NextAction != "Persisted action" { + t.Fatalf("restored commitment missing: %+v", st.Commitment) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +go test ./internal/session/ +``` + +Expected: compile failure — `Controller`, `New`, and methods undefined. + +- [ ] **Step 3: Implement the session controller** + +Create `internal/session/session.go`: + +```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. +package session + +import ( + "sync" + "time" + + "antidrift/internal/domain" + "antidrift/internal/statemachine" + "antidrift/internal/store" +) + +// 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 +} + +// 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"` +} + +// State is the broadcastable view of the controller. +type State struct { + RuntimeState domain.RuntimeState `json:"runtime_state"` + Commitment *CommitmentView `json:"commitment"` +} + +// New loads any persisted snapshot, or starts Locked. +func New(snapshotPath string) (*Controller, error) { + s, err := store.Load(snapshotPath) + if err != nil { + return nil, err + } + c := &Controller{ + runtimeState: s.RuntimeState, + commitment: s.Commitment, + snapshotPath: snapshotPath, + } + if s.DeadlineUnixSecs > 0 { + c.deadline = time.Unix(s.DeadlineUnixSecs, 0) + } + if c.runtimeState == "" { + c.runtimeState = domain.RuntimeLocked + } + return c, nil +} + +// 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 { + st.Commitment = &CommitmentView{ + NextAction: c.commitment.NextAction, + SuccessCondition: c.commitment.SuccessCondition, + TimeboxSecs: c.commitment.TimeboxSecs, + DeadlineUnixSecs: c.deadline.Unix(), + } + } + return st +} + +func (c *Controller) persistLocked() error { + snap := store.Snapshot{RuntimeState: c.runtimeState, Commitment: c.commitment} + if !c.deadline.IsZero() { + snap.DeadlineUnixSecs = c.deadline.Unix() + } + 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, 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 + } + c.runtimeState = next + c.commitment = &commitment + c.deadline = time.Now().Add(timebox) + return c.persistLocked() +} + +// Complete moves Active -> Review and marks the commitment completed. +func (c *Controller) Complete() error { + c.mu.Lock() + defer c.mu.Unlock() + next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) + if err != nil { + return err + } + c.runtimeState = next + if c.commitment != nil { + if s, e := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete); e == nil { + c.commitment.State = s + } + } + return c.persistLocked() +} + +// End moves Review -> Locked and clears the commitment. +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 + } + c.runtimeState = next + c.commitment = nil + c.deadline = time.Time{} + return c.persistLocked() +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +go test ./internal/session/ +``` + +Expected: PASS (all three tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/session/ +git commit -m "Add session controller with snapshot persistence" +``` + +## Task 6: SSE Broadcaster + +**Files:** +- Create: `internal/web/broadcaster.go` +- Test: `internal/web/broadcaster_test.go` + +- [ ] **Step 1: Write the failing broadcaster test** + +Create `internal/web/broadcaster_test.go`: + +```go +package web + +import ( + "testing" + "time" +) + +func TestBroadcasterDeliversToSubscribers(t *testing.T) { + b := NewBroadcaster() + ch := b.Subscribe() + defer b.Unsubscribe(ch) + + b.Publish("hello") + + select { + case msg := <-ch: + if msg != "hello" { + t.Fatalf("got %q want hello", msg) + } + case <-time.After(time.Second): + t.Fatal("subscriber did not receive message") + } +} + +func TestBroadcasterDropsWhenSubscriberFull(t *testing.T) { + b := NewBroadcaster() + ch := b.Subscribe() + defer b.Unsubscribe(ch) + // Publishing many messages without reading must not block (slow client). + done := make(chan struct{}) + go func() { + for i := 0; i < 100; i++ { + b.Publish("x") + } + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Publish blocked on a full subscriber") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```bash +go test ./internal/web/ +``` + +Expected: compile failure — `NewBroadcaster` undefined. + +- [ ] **Step 3: Implement the broadcaster** + +Create `internal/web/broadcaster.go`: + +```go +package web + +import "sync" + +// Broadcaster fans a string message out to all SSE subscribers. Slow +// subscribers drop messages rather than blocking publishers. +type Broadcaster struct { + mu sync.Mutex + subs map[chan string]struct{} +} + +func NewBroadcaster() *Broadcaster { + return &Broadcaster{subs: make(map[chan string]struct{})} +} + +func (b *Broadcaster) Subscribe() chan string { + ch := make(chan string, 8) + b.mu.Lock() + b.subs[ch] = struct{}{} + b.mu.Unlock() + return ch +} + +func (b *Broadcaster) Unsubscribe(ch chan string) { + b.mu.Lock() + if _, ok := b.subs[ch]; ok { + delete(b.subs, ch) + close(ch) + } + b.mu.Unlock() +} + +func (b *Broadcaster) Publish(msg string) { + b.mu.Lock() + defer b.mu.Unlock() + for ch := range b.subs { + select { + case ch <- msg: + default: // drop for slow subscriber + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: + +```bash +go test ./internal/web/ -run TestBroadcaster +``` + +Expected: PASS (both broadcaster tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/broadcaster.go internal/web/broadcaster_test.go +git commit -m "Add SSE broadcaster" +``` + +## Task 7: Web Server And Handlers + +**Files:** +- Create: `internal/web/web.go` +- Create: `internal/web/static/index.html` +- Test: `internal/web/web_test.go` + +- [ ] **Step 1: Create the static UI placeholder so embedding compiles** + +Create `internal/web/static/index.html` with a minimal valid page (it is replaced with the real UI in Step 7): + +```html +AntiDrift +``` + +- [ ] **Step 2: Write the failing web tests** + +Create `internal/web/web_test.go`: + +```go +package web + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "antidrift/internal/domain" + "antidrift/internal/session" + + "github.com/gin-gonic/gin" +) + +func newTestServer(t *testing.T) *Server { + t.Helper() + gin.SetMode(gin.TestMode) + path := filepath.Join(t.TempDir(), "state.json") + ctrl, err := session.New(path) + if err != nil { + t.Fatalf("controller: %v", err) + } + return NewServer(ctrl) +} + +func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestPlanningThenCommitmentReachesActive(t *testing.T) { + s := newTestServer(t) + r := s.Router() + + if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + t.Fatalf("/planning code %d", w.Code) + } + 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()) + } + if s.ctrl.State().RuntimeState != domain.RuntimeActive { + t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState) + } +} + +func TestCommitmentRejectsInvalidInput(t *testing.T) { + s := newTestServer(t) + r := s.Router() + _ = post(t, r, "/planning", "") + body := `{"next_action":"","success_condition":"x","timebox_secs":1500}` + w := post(t, r, "/commitment", body) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestIllegalTransitionReturns409(t *testing.T) { + s := newTestServer(t) + r := s.Router() + // /complete from Locked is illegal. + w := post(t, r, "/complete", "") + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: + +```bash +go test ./internal/web/ -run 'Test(Planning|Commitment|Illegal)' +``` + +Expected: compile failure — `Server`, `NewServer`, `Router` undefined. + +- [ ] **Step 4: Add the gin dependency** + +Run: + +```bash +go get github.com/gin-gonic/gin@latest +``` + +Expected: `go.mod` / `go.sum` updated. + +- [ ] **Step 5: Implement the server, handlers, and expiry timer** + +Create `internal/web/web.go`: + +```go +// Package web serves the local UI and an SSE state stream, and owns the +// server-authoritative timebox expiry timer. +package web + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "sync" + "time" + + "antidrift/internal/session" + "antidrift/internal/statemachine" + + "github.com/gin-gonic/gin" +) + +//go:embed static/* +var staticFS embed.FS + +// Server wires the session controller to HTTP and SSE. +type Server struct { + ctrl *session.Controller + bcast *Broadcaster + + mu sync.Mutex + timer *time.Timer +} + +func NewServer(ctrl *session.Controller) *Server { + return &Server{ctrl: ctrl, bcast: NewBroadcaster()} +} + +func (s *Server) Router() *gin.Engine { + r := gin.New() + r.Use(gin.Recovery()) + + sub, _ := fs.Sub(staticFS, "static") + r.GET("/", func(c *gin.Context) { + c.FileFromFS("/", http.FS(sub)) + }) + r.GET("/events", s.handleEvents) + r.POST("/planning", s.handlePlanning) + r.POST("/commitment", s.handleCommitment) + r.POST("/complete", s.handleComplete) + r.POST("/end", s.handleEnd) + return r +} + +func (s *Server) stateJSON() string { + data, _ := json.Marshal(s.ctrl.State()) + return string(data) +} + +func (s *Server) broadcast() { + s.bcast.Publish(s.stateJSON()) +} + +// respond maps a controller error to an HTTP status, otherwise broadcasts and +// returns the new state. +func (s *Server) respond(c *gin.Context, err error) { + if err != nil { + var illegal statemachine.IllegalTransitionError + if errors.As(err, &illegal) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.broadcast() + c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) +} + +func (s *Server) handlePlanning(c *gin.Context) { + s.cancelExpiry() + s.respond(c, s.ctrl.EnterPlanning()) +} + +type commitmentRequest struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` +} + +func (s *Server) handleCommitment(c *gin.Context) { + var req commitmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second) + if err == nil { + s.armExpiry() + } + s.respond(c, err) +} + +func (s *Server) handleComplete(c *gin.Context) { + s.cancelExpiry() + s.respond(c, s.ctrl.Complete()) +} + +func (s *Server) handleEnd(c *gin.Context) { + s.respond(c, s.ctrl.End()) +} + +func (s *Server) handleEvents(c *gin.Context) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.Status(http.StatusInternalServerError) + return + } + ch := s.bcast.Subscribe() + defer s.bcast.Unsubscribe(ch) + + fmt.Fprintf(c.Writer, "data: %s\n\n", s.stateJSON()) + flusher.Flush() + + for { + select { + case <-c.Request.Context().Done(): + return + case msg, open := <-ch: + if !open { + return + } + fmt.Fprintf(c.Writer, "data: %s\n\n", msg) + flusher.Flush() + } + } +} + +// armExpiry schedules an Active -> Review transition at the commitment deadline. +func (s *Server) armExpiry() { + deadline := s.ctrl.Deadline() + if deadline.IsZero() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.timer != nil { + s.timer.Stop() + } + d := time.Until(deadline) + if d < 0 { + d = 0 + } + s.timer = time.AfterFunc(d, func() { + if err := s.ctrl.Complete(); err == nil { + s.broadcast() + } + }) +} + +func (s *Server) cancelExpiry() { + s.mu.Lock() + defer s.mu.Unlock() + if s.timer != nil { + s.timer.Stop() + s.timer = nil + } +} + +// Init re-arms or expires a restored Active session at startup. +func (s *Server) Init() { + st := s.ctrl.State() + if st.RuntimeState != "active" { + return + } + if s.ctrl.Deadline().After(time.Now()) { + s.armExpiry() + return + } + if err := s.ctrl.Complete(); err == nil { + s.broadcast() + } +} +``` + +- [ ] **Step 6: Run the web tests to verify they pass** + +Run: + +```bash +go test ./internal/web/ +``` + +Expected: PASS (broadcaster tests + the three handler tests). + +- [ ] **Step 7: Replace the static placeholder with the real UI** + +Overwrite `internal/web/static/index.html`: + +```html + + + + + +AntiDrift + + + +
+

AntiDrift

+
connecting…
+
+ + + +``` + +- [ ] **Step 8: Verify everything still builds and tests pass** + +Run: + +```bash +go test ./... +``` + +Expected: PASS across all packages. + +- [ ] **Step 9: Commit** + +```bash +git add internal/web/ go.mod go.sum +git commit -m "Add web server, SSE handlers, and UI" +``` + +## Task 8: Daemon Wiring + +**Files:** +- Modify: `cmd/antidriftd/main.go` + +- [ ] **Step 1: Implement the daemon entrypoint** + +Replace `cmd/antidriftd/main.go`: + +```go +// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI +// and owns the commitment state machine. +package main + +import ( + "log" + "os/exec" + "runtime" + "time" + + "antidrift/internal/session" + "antidrift/internal/store" + "antidrift/internal/web" +) + +const addr = "localhost:7777" + +func main() { + path, err := store.DefaultPath() + if err != nil { + log.Fatalf("resolve snapshot path: %v", err) + } + ctrl, err := session.New(path) + if err != nil { + log.Fatalf("load session: %v", err) + } + + srv := web.NewServer(ctrl) + srv.Init() // re-arm or expire a restored Active session + + go openBrowser("http://" + addr) + + log.Printf("antidriftd listening on http://%s", addr) + if err := srv.Router().Run(addr); err != nil { + log.Fatalf("server: %v", err) + } +} + +// openBrowser best-effort launches the default browser after a short delay so +// the server is listening first. Failures are logged, not fatal. +func openBrowser(url string) { + time.Sleep(300 * time.Millisecond) + var cmd string + var args []string + switch runtime.GOOS { + case "linux": + cmd, args = "xdg-open", []string{url} + case "darwin": + cmd, args = "open", []string{url} + case "windows": + cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url} + default: + log.Printf("open browser manually: %s", url) + return + } + if err := exec.Command(cmd, args...).Start(); err != nil { + log.Printf("could not open browser (%v); visit %s", err, url) + } +} +``` + +- [ ] **Step 2: Build the daemon** + +Run: + +```bash +go build ./... +``` + +Expected: builds with no output. + +- [ ] **Step 3: Manual smoke test** + +Run: + +```bash +go run ./cmd/antidriftd +``` + +Expected: + +- log line `antidriftd listening on http://localhost:7777`; +- a browser opens to the page showing the Locked view; +- click "Start planning" → fill next action, success condition, minutes → "Start commitment" → the Active view shows a counting-down timer; +- click "Complete" → Review view → "End" → back to Locked; +- start a short commitment (1 minute) and let it run out → it flips to Review automatically. + +Stop with Ctrl-C. + +- [ ] **Step 4: Verify restart restores state** + +Run: + +```bash +go run ./cmd/antidriftd +``` + +Start a commitment, note the action, then Ctrl-C and re-run: + +```bash +go run ./cmd/antidriftd +``` + +Expected: the Active view returns with the same next action and a still-counting timer (or it shows Review if the timebox elapsed while stopped). Confirm `~/.antidrift/state.json` exists. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/antidriftd/main.go +git commit -m "Wire antidriftd daemon entrypoint" +``` + +## Task 9: Final Verification And README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update the README** + +Replace the body of `README.md` with: + +```markdown +# AntiDrift + +A personal focus operating system: treat each work session as an explicit +commitment (next action, success condition, timebox), and make drift visible. + +This is the Go reimagining. The original Rust implementation is preserved under +`legacy/` for reference. See `docs/superpowers/specs/` for the design. + +## Run + +```bash +go run ./cmd/antidriftd +``` + +The daemon serves a local web UI at http://localhost:7777 and opens your +browser. State is persisted to `~/.antidrift/state.json`. + +## Test + +```bash +go test ./... +``` + +## Status + +M0 (walking skeleton): manual commitment lifecycle over a local web UI with +snapshot persistence. AI integration, active-window tracking, and the +tamper-evident audit log arrive in later milestones (see the roadmap in +`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`). +``` + +- [ ] **Step 2: Run the full test suite** + +Run: + +```bash +go test ./... +``` + +Expected: PASS across `internal/domain`, `internal/statemachine`, `internal/store`, `internal/session`, `internal/web`. + +- [ ] **Step 3: Vet the code** + +Run: + +```bash +go vet ./... +``` + +Expected: no output (no issues). + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "Document M0 walking skeleton" +``` + +## Self-Review Notes + +- **Spec coverage:** domain port (Task 2), statemachine port (Task 3), snapshot store (Task 4), session controller with persistence (Task 5), SSE (Task 6), Gin server + HTTP surface + server-authoritative expiry + UI (Task 7), daemon with browser-open and restart restore (Task 8), repo/module setup with Rust moved to `legacy/` (Task 1). All M0 spec sections map to a task. +- **HTTP surface:** `/`, `/events`, `/planning`, `/commitment`, `/complete`, `/end` all implemented; invalid input → 400, illegal transition → 409, as specified. +- **State flow:** Locked → Planning → Active → Review → Locked exercised through the ported pure transition functions; full enums present for later milestones. +- **Out of scope honored:** no AI, no window tracking, no audit log, no allowed-context matching. +- **Type consistency:** `Controller`, `State`, `CommitmentView`, `Snapshot`, `Server`, `Broadcaster`, and the `statemachine` action constants (`CommitmentActivate`, `ActivateAccepted`, `CompleteForReview`, `EndWorkPeriod`) are referenced consistently across Tasks 3, 5, and 7. +```