Files
antidrift/docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md
T
2026-05-31 11:31:17 -04:00

48 KiB

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.goCommitment, PolicySnapshot, AllowedContext, state/enforcement enums, validation. (domain_test.go)
  • internal/statemachine/statemachine.go — pure TransitionRuntime / TransitionCommitment. (statemachine_test.go)
  • internal/store/store.goSnapshot, Load, Save, DefaultPath. (store_test.go)
  • internal/session/session.goController (in-memory state + persistence), State projection. (session_test.go)
  • internal/web/broadcaster.go — SSE fan-out Broadcaster. (broadcaster_test.go)
  • internal/web/web.goServer, 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.desktoplegacy/

  • Modify: .gitignore

  • Step 1: Move the Rust sources into legacy/

Run:

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:

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:

# Go
/antidriftd
*.test
  • Step 4: Create a minimal entrypoint so the module builds

Create cmd/antidriftd/main.go:

package main

import "fmt"

func main() {
	fmt.Println("antidriftd")
}
  • Step 5: Verify the module builds

Run:

go build ./...

Expected: builds with no output.

  • Step 6: Commit
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:

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:

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:

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:

// 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:

go test ./internal/domain/

Expected: PASS (all five tests).

  • Step 6: Commit
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:

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:

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:

// 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:

go test ./internal/statemachine/

Expected: PASS (all transition tests).

  • Step 5: Commit
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:

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:

go test ./internal/store/

Expected: compile failure — Snapshot, Load, Save undefined.

  • Step 3: Implement the store

Create internal/store/store.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:

go test ./internal/store/

Expected: PASS (both tests).

  • Step 5: Commit
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:

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:

go test ./internal/session/

Expected: compile failure — Controller, New, and methods undefined.

  • Step 3: Implement the session controller

Create internal/session/session.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:

go test ./internal/session/

Expected: PASS (all three tests).

  • Step 5: Commit
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:

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:

go test ./internal/web/

Expected: compile failure — NewBroadcaster undefined.

  • Step 3: Implement the broadcaster

Create internal/web/broadcaster.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:

go test ./internal/web/ -run TestBroadcaster

Expected: PASS (both broadcaster tests).

  • Step 5: Commit
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):

<!doctype html><html><head><meta charset="utf-8"><title>AntiDrift</title></head><body></body></html>
  • Step 2: Write the failing web tests

Create internal/web/web_test.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:

go test ./internal/web/ -run 'Test(Planning|Commitment|Illegal)'

Expected: compile failure — Server, NewServer, Router undefined.

  • Step 4: Add the gin dependency

Run:

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:

// 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:

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:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AntiDrift</title>
<style>
  :root { color-scheme: dark; }
  body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: #11131a; color: #e6e8ee; }
  main { max-width: 640px; margin: 8vh auto; padding: 0 24px; }
  h1 { font-size: 14px; letter-spacing: .2em; text-transform: uppercase; color: #7a8095; }
  .card { background: #1a1d27; border: 1px solid #272b38; border-radius: 14px; padding: 28px; }
  label { display: block; font-size: 13px; color: #9aa0b4; margin: 16px 0 6px; }
  input { width: 100%; box-sizing: border-box; padding: 10px 12px; background: #11131a;
          border: 1px solid #2d3242; border-radius: 8px; color: #e6e8ee; font: inherit; }
  button { margin-top: 22px; padding: 11px 18px; border: 0; border-radius: 8px;
           background: #4c6ef5; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
  button:disabled { background: #2d3242; color: #6a7186; cursor: not-allowed; }
  .timer { font-size: 56px; font-weight: 700; font-variant-numeric: tabular-nums; margin: 8px 0 4px; }
  .action { font-size: 22px; font-weight: 600; }
  .meta { color: #9aa0b4; }
  .pill { display: inline-block; font-size: 12px; letter-spacing: .15em; text-transform: uppercase;
          color: #7a8095; border: 1px solid #272b38; border-radius: 999px; padding: 3px 10px; }
</style>
</head>
<body>
<main>
  <h1>AntiDrift</h1>
  <div class="card" id="view">connecting…</div>
</main>
<script>
const view = document.getElementById('view');
let countdownTimer = null;

function post(path, body) {
  return fetch(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
}

function fmt(secs) {
  secs = Math.max(0, Math.floor(secs));
  const m = String(Math.floor(secs / 60)).padStart(2, '0');
  const s = String(secs % 60).padStart(2, '0');
  return `${m}:${s}`;
}

function render(state) {
  if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
  const rs = state.runtime_state;
  if (rs === 'locked') {
    view.innerHTML = `<span class="pill">Locked</span>
      <p class="meta">No active commitment.</p>
      <button id="plan">Start planning</button>`;
    document.getElementById('plan').onclick = () => post('/planning');
  } else if (rs === 'planning') {
    view.innerHTML = `<span class="pill">Planning</span>
      <label>Next action</label><input id="na" placeholder="What exactly will you do?">
      <label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
      <label>Minutes</label><input id="mins" type="number" min="1" value="25">
      <button id="start" disabled>Start commitment</button>`;
    const na = document.getElementById('na'), sc = document.getElementById('sc'),
          mins = document.getElementById('mins'), start = document.getElementById('start');
    const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
    [na, sc, mins].forEach(el => el.oninput = check);
    start.onclick = () => post('/commitment', {
      next_action: na.value.trim(),
      success_condition: sc.value.trim(),
      timebox_secs: Math.round(+mins.value * 60),
    });
  } else if (rs === 'active') {
    const c = state.commitment || {};
    view.innerHTML = `<span class="pill">Active</span>
      <div class="timer" id="t">--:--</div>
      <div class="action">${c.next_action || ''}</div>
      <p class="meta">Done when: ${c.success_condition || ''}</p>
      <button id="done">Complete</button>`;
    document.getElementById('done').onclick = () => post('/complete');
    const t = document.getElementById('t');
    const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
    tick();
    countdownTimer = setInterval(tick, 250);
  } else if (rs === 'review') {
    const c = state.commitment || {};
    view.innerHTML = `<span class="pill">Review</span>
      <p class="action">Session ended</p>
      <p class="meta">${c.next_action || ''}</p>
      <button id="end">End</button>`;
    document.getElementById('end').onclick = () => post('/end');
  } else {
    view.textContent = rs;
  }
}

const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
</script>
</body>
</html>
  • Step 8: Verify everything still builds and tests pass

Run:

go test ./...

Expected: PASS across all packages.

  • Step 9: Commit
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:

// 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:

go build ./...

Expected: builds with no output.

  • Step 3: Manual smoke test

Run:

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:

go run ./cmd/antidriftd

Start a commitment, note the action, then Ctrl-C and re-run:

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
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:

# 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

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:

go vet ./...

Expected: no output (no issues).

  • Step 4: Commit
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.