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 +