# 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