diff --git a/docs/superpowers/plans/2026-06-01-settings-page.md b/docs/superpowers/plans/2026-06-01-settings-page.md new file mode 100644 index 0000000..b2ff555 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-settings-page.md @@ -0,0 +1,996 @@ +# Settings file + settings page 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:** Let the user edit the AI backend, Marvin tasks command, and knowledge profile path at runtime from a settings page in the web UI, persisted to a settings file, with a server-side file picker for the profile path. + +**Architecture:** A new leaf `internal/settings` package owns the `Settings` struct and its JSON file (`~/.antidrift/settings.json`). `main.go` builds an `applyFn func(settings.Settings) error` closure that constructs adapters and calls the controller's existing setters, and injects it into the web server. The server's `POST /settings` handler validates-and-applies via that closure (atomically — invalid backend mutates nothing), then persists. A `GET /fs/browse` endpoint backs a custom file-picker modal. `web` never imports `ai`/`tasks`/`knowledge`. + +**Tech Stack:** Go, Gin, vanilla-JS SSE frontend. Spec: `docs/superpowers/specs/2026-06-01-settings-page-design.md`. + +--- + +## File structure + +- `internal/settings/settings.go` (new) — `Settings` struct, `ErrInvalidBackend`, `DefaultPath`, `Load`, `Save`, `SeedFromEnv`. +- `internal/settings/settings_test.go` (new) — round-trip, seed, first-run. +- `internal/web/settings_handlers.go` (new) — server settings fields, `SetSettings`, `handleGetSettings`, `handlePostSettings`, `handleBrowse`. +- `internal/web/settings_handlers_test.go` (new) — GET/POST/browse tests with a fake applier. +- `internal/web/web.go` (modify) — register routes; drop `POST /knowledge/path` + `handleKnowledgePath`. +- `cmd/antidriftd/main.go` (modify) — load-or-seed settings, build `applyFn`, inject into server. +- `internal/web/static/index.html` (modify) — gear button + overlay container. +- `internal/web/static/app.js` (modify) — settings overlay + browse modal; repoint knowledge "change" link. +- `internal/web/static/app.css` (modify) — gear, overlay, modal, browse styles. + +--- + +## Task 1: `internal/settings` package + +**Files:** +- Create: `internal/settings/settings.go` +- Test: `internal/settings/settings_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/settings/settings_test.go`: + +```go +package settings + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} + if err := Save(path, want); err != nil { + t.Fatalf("save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != want { + t.Errorf("round trip = %+v, want %+v", got, want) + } +} + +func TestLoadMissingFileIsError(t *testing.T) { + _, err := Load(filepath.Join(t.TempDir(), "nope.json")) + if err == nil { + t.Fatal("want error loading missing file, got nil") + } +} + +func TestSeedFromEnvReadsVars(t *testing.T) { + t.Setenv("ANTIDRIFT_AI_BACKEND", "codex") + t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am") + t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md") + got := SeedFromEnv() + want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} + if got != want { + t.Errorf("SeedFromEnv = %+v, want %+v", got, want) + } +} + +func TestSeedFromEnvDefaultsBackend(t *testing.T) { + t.Setenv("ANTIDRIFT_AI_BACKEND", "") + t.Setenv("ANTIDRIFT_MARVIN_CMD", "") + t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "") + got := SeedFromEnv() + if got.AIBackend != "claude" { + t.Errorf("default backend = %q, want claude", got.AIBackend) + } +} + +func TestSaveCreatesDir(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "settings.json") + if err := Save(path, Settings{AIBackend: "claude"}); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("file not written: %v", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/settings/` +Expected: FAIL — `undefined: Settings`, `undefined: Save`, etc. + +- [ ] **Step 3: Write the implementation** + +Create `internal/settings/settings.go`: + +```go +// Package settings persists the daemon's user-editable configuration as a single +// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a +// leaf type package: it imports nothing else in the app so any layer may depend +// on the Settings value without pulling in adapters. +package settings + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// ErrInvalidBackend marks a settings value whose ai_backend is not a known +// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400 +// without importing the ai package. +var ErrInvalidBackend = errors.New("settings: invalid ai backend") + +// Settings is the user-editable configuration. Field names mirror the env vars +// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE. +type Settings struct { + AIBackend string `json:"ai_backend"` + MarvinCmd string `json:"marvin_cmd"` + KnowledgePath string `json:"knowledge_path"` +} + +// DefaultPath returns ~/.antidrift/settings.json. +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".antidrift", "settings.json"), nil +} + +// Load reads a settings file. A missing file is an error; callers treat that as +// "first run" and seed instead. +func Load(path string) (Settings, error) { + data, err := os.ReadFile(path) + if err != nil { + return Settings{}, err + } + var s Settings + if err := json.Unmarshal(data, &s); err != nil { + return Settings{}, err + } + return s, nil +} + +// Save writes settings atomically (temp file + rename), creating the directory +// if needed. Mirrors store.Save. +func Save(path string, s Settings) 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) +} + +// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying +// the same defaults the daemon used before the settings file existed. An unset +// AI backend defaults to "claude" (matching ai.NewBackend("")). +func SeedFromEnv() Settings { + backend := os.Getenv("ANTIDRIFT_AI_BACKEND") + if backend == "" { + backend = "claude" + } + return Settings{ + AIBackend: backend, + MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"), + KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"), + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/settings/` +Expected: PASS (all 5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/settings/ +git commit -m "Add settings package: Settings file load/save/seed + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: Web settings handlers (GET + POST) + +**Files:** +- Create: `internal/web/settings_handlers.go` +- Test: `internal/web/settings_handlers_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/web/settings_handlers_test.go`: + +```go +package web + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + + "antidrift/internal/settings" +) + +// fakeApplier records the last settings it was asked to apply and can be told to +// reject with ErrInvalidBackend. +type fakeApplier struct { + called int + last settings.Settings + reject bool +} + +func (f *fakeApplier) apply(s settings.Settings) error { + if f.reject { + return settings.ErrInvalidBackend + } + f.called++ + f.last = s + return nil +} + +func TestGetSettingsReturnsCurrent(t *testing.T) { + s := newTestServer(t) + cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"} + fa := &fakeApplier{} + s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply) + r := s.Router() + + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var got settings.Settings + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got != cur { + t.Errorf("got %+v, want %+v", got, cur) + } +} + +func TestPostSettingsValidAppliesAndSaves(t *testing.T) { + s := newTestServer(t) + path := filepath.Join(t.TempDir(), "settings.json") + fa := &fakeApplier{} + s.SetSettings(path, settings.Settings{}, fa.apply) + r := s.Router() + + body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}` + w := post(t, r, "/settings", body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String()) + } + if fa.called != 1 { + t.Fatalf("applier called %d times, want 1", fa.called) + } + if fa.last.AIBackend != "codex" { + t.Errorf("applied backend = %q, want codex", fa.last.AIBackend) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("settings file not written: %v", err) + } +} + +func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) { + s := newTestServer(t) + path := filepath.Join(t.TempDir(), "settings.json") + fa := &fakeApplier{reject: true} + s.SetSettings(path, settings.Settings{}, fa.apply) + r := s.Router() + + w := post(t, r, "/settings", `{"ai_backend":"bogus"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("settings file should not exist after rejected save, stat err = %v", err) + } +} +``` + +Note: `httptest`, `post`, and `newTestServer` already exist in `web_test.go` (same package), so they are reused here. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/web/ -run TestSettings -v` (and `TestGetSettings`, `TestPostSettings`) +Run: `go test ./internal/web/ -run 'Settings' -v` +Expected: FAIL — `s.SetSettings undefined`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/web/settings_handlers.go`: + +```go +package web + +import ( + "net/http" + + "antidrift/internal/settings" + + "github.com/gin-gonic/gin" +) + +// SetSettings injects the persisted settings, their file path, and the applier +// closure (built by main, which owns adapter construction). Mirrors the +// controller's Set* injection so web never imports ai/tasks/knowledge. +func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) { + s.settingsMu.Lock() + s.settingsPath = path + s.settings = current + s.applyFn = apply + s.settingsMu.Unlock() +} + +func (s *Server) handleGetSettings(c *gin.Context) { + s.settingsMu.Lock() + cur := s.settings + s.settingsMu.Unlock() + c.JSON(http.StatusOK, cur) +} + +// handlePostSettings validates-and-applies atomically, then persists. The applier +// checks the backend before mutating any state, so an invalid backend yields 400 +// with nothing saved and the prior wiring intact. +func (s *Server) handlePostSettings(c *gin.Context) { + var req settings.Settings + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + s.settingsMu.Lock() + apply := s.applyFn + path := s.settingsPath + s.settingsMu.Unlock() + if apply == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"}) + return + } + if err := apply(req); err != nil { + // Apply validates the backend before mutating anything, so any error + // (invalid backend or otherwise) means nothing changed; surface it as 400. + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := settings.Save(path, req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + s.settingsMu.Lock() + s.settings = req + s.settingsMu.Unlock() + s.broadcast() + c.JSON(http.StatusOK, req) +} +``` + +Add the server fields. In `internal/web/web.go`, modify the `Server` struct (currently lines 26-32) to: + +```go +// Server wires the session controller to HTTP and SSE. +type Server struct { + ctrl *session.Controller + bcast *Broadcaster + + mu sync.Mutex + timer *time.Timer + + settingsMu sync.Mutex + settings settings.Settings + settingsPath string + applyFn func(settings.Settings) error +} +``` + +Add the import `"antidrift/internal/settings"` to `web.go`'s import block. + +Register the routes in `internal/web/web.go` `Router()`, after the existing `r.POST(...)` lines (around line 71): + +```go + r.GET("/settings", s.handleGetSettings) + r.POST("/settings", s.handlePostSettings) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/web/ -run 'Settings' -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go +git commit -m "Add GET/POST /settings handlers with injected applier + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: `GET /fs/browse` file-list endpoint + +**Files:** +- Modify: `internal/web/settings_handlers.go` +- Test: `internal/web/settings_handlers_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/web/settings_handlers_test.go`: + +```go +func TestBrowseListsDirsAndMarkdown(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + s := newTestServer(t) + s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) + r := s.Router() + + req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp struct { + Dir string `json:"dir"` + Parent string `json:"parent"` + Entries []struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + } `json:"entries"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Parent != filepath.Dir(dir) { + t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir)) + } + var names []string + for _, e := range resp.Entries { + names = append(names, e.Name) + } + hasSub, hasMd, hasTxt := false, false, false + for _, e := range resp.Entries { + switch e.Name { + case "sub": + hasSub = e.IsDir + case "profile.md": + hasMd = !e.IsDir + case "notes.txt": + hasTxt = true + } + } + if !hasSub || !hasMd { + t.Errorf("missing dir or .md entry; names = %v", names) + } + if hasTxt { + t.Errorf(".txt should be filtered out; names = %v", names) + } +} + +func TestBrowseBadDirIs400(t *testing.T) { + s := newTestServer(t) + s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) + r := s.Router() + req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/web/ -run TestBrowse -v` +Expected: FAIL — 404 (route not registered) / `handleBrowse undefined`. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/web/settings_handlers.go` (add `"os"`, `"path/filepath"`, `"strings"` to its imports): + +```go +type browseEntry struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` +} + +type browseResponse struct { + Dir string `json:"dir"` + Parent string `json:"parent"` + Entries []browseEntry `json:"entries"` +} + +// handleBrowse lists subdirectories plus .md files under dir, so the UI can build +// a file picker that returns a real server-side path. Dotfiles are NOT hidden — +// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail +// beyond OS permissions (same trust boundary as the rest of the UI). +func (s *Server) handleBrowse(c *gin.Context) { + dir := strings.TrimSpace(c.Query("dir")) + if dir == "" { + dir = s.browseStart() + } + dir = filepath.Clean(dir) + infos, err := os.ReadDir(dir) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + resp := browseResponse{Dir: dir, Parent: filepath.Dir(dir), Entries: []browseEntry{}} + for _, e := range infos { + name := e.Name() + full := filepath.Join(dir, name) + if e.IsDir() { + resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full, IsDir: true}) + } else if strings.HasSuffix(name, ".md") { + resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full}) + } + } + c.JSON(http.StatusOK, resp) +} + +// browseStart picks the directory of the current knowledge path, else $HOME. +func (s *Server) browseStart() string { + s.settingsMu.Lock() + kp := s.settings.KnowledgePath + s.settingsMu.Unlock() + if kp != "" { + return filepath.Dir(kp) + } + if home, err := os.UserHomeDir(); err == nil { + return home + } + return "/" +} +``` + +Register the route in `internal/web/web.go` `Router()`, next to the other settings routes: + +```go + r.GET("/fs/browse", s.handleBrowse) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/web/ -run TestBrowse -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go +git commit -m "Add GET /fs/browse directory-listing endpoint + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4: Remove the now-redundant `/knowledge/path` endpoint + +**Files:** +- Modify: `internal/web/web.go` + +- [ ] **Step 1: Delete the route and handler** + +In `internal/web/web.go`, remove the route registration line: + +```go + r.POST("/knowledge/path", s.handleKnowledgePath) +``` + +and delete the `knowledgePathRequest` type plus the `handleKnowledgePath` function (currently `web.go:143-159`): + +```go +type knowledgePathRequest struct { + Path string `json:"path"` +} + +// handleKnowledgePath repoints the profile file at runtime ... +func (s *Server) handleKnowledgePath(c *gin.Context) { + ... +} +``` + +The controller's `SetKnowledgePath` method stays — the applier (Task 5) calls it. + +- [ ] **Step 2: Update the test that exercises the removed route** + +Search for any test referencing `/knowledge/path`: + +Run: `grep -rn "knowledge/path" internal/web/` + +If `TestPlanningStatePayloadCarriesKnowledge` (or any test) POSTs to `/knowledge/path`, repoint it to `/settings`. Concretely, replace a call like +`post(t, r, "/knowledge/path", `+"`"+`{"path":"/tmp/x.md"}`+"`"+`)` +with wiring settings first and posting the full object: + +```go + s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), + settings.Settings{}, func(settings.Settings) error { return nil }) + post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/tmp/x.md"}`) +``` + +If no test references it, skip this step. + +- [ ] **Step 3: Verify build and tests** + +Run: `go build ./... && go test ./internal/web/` +Expected: PASS, no references to `handleKnowledgePath` remain. + +Run: `grep -rn "knowledge/path\|handleKnowledgePath" internal/` +Expected: no matches. + +- [ ] **Step 4: Commit** + +```bash +git add internal/web/ +git commit -m "Remove /knowledge/path; folded into /settings + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 5: Wire settings into `main.go` (applier + first-run seed) + +**Files:** +- Modify: `cmd/antidriftd/main.go` + +- [ ] **Step 1: Replace the env-var wiring with settings load-or-seed + applier** + +In `cmd/antidriftd/main.go`, replace the three wiring blocks (currently lines 39-65: the AI backend block, the `SetTasks` block, and the `SetKnowledge` block) with the following. Keep everything before (`store.DefaultPath`, `session.New`, `web.NewServer`, `srv.Init()`) and after (statusfile, guard, evidence, browser, Run) unchanged. + +```go + // Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on + // first run. After first run the file is the sole source of truth; env is + // ignored. A missing file is the first-run signal. + settingsPath, err := settings.DefaultPath() + if err != nil { + log.Fatalf("resolve settings path: %v", err) + } + cfg, err := settings.Load(settingsPath) + if err != nil { + cfg = settings.SeedFromEnv() + if err := settings.Save(settingsPath, cfg); err != nil { + log.Printf("settings: could not write %s (continuing): %v", settingsPath, err) + } else { + log.Printf("settings: seeded %s from environment", settingsPath) + } + } + + // The knowledge source is a single file adapter whose path is driven entirely + // by SetKnowledgePath, so startup and live settings edits share one code path. + ctrl.SetKnowledge(knowledge.NewFileSource("")) + + // applyFn re-wires the running daemon from a Settings value: AI backend + + // service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It + // validates the backend FIRST and mutates nothing on failure, so POST /settings + // can reject an invalid backend atomically. + applyFn := func(s settings.Settings) error { + backend, err := ai.NewBackend(s.AIBackend) + if err != nil { + return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err) + } + svc := ai.NewService(backend) + ctrl.SetCoach(svc) + ctrl.SetDriftJudge(svc) + ctrl.SetNudge(svc) + ctrl.SetReviewer(svc) + ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd)) + ctrl.SetKnowledgePath(s.KnowledgePath) + return nil + } + + // Apply once at startup. A bad persisted backend should not be fatal: fall back + // to claude (always valid), persist the correction, and re-apply. + if err := applyFn(cfg); err != nil { + log.Printf("settings: %v; falling back to claude backend", err) + cfg.AIBackend = "claude" + _ = settings.Save(settingsPath, cfg) + if err := applyFn(cfg); err != nil { + log.Fatalf("settings: apply failed even with claude: %v", err) + } + } + srv.SetSettings(settingsPath, cfg, applyFn) + log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath) +``` + +Add `"fmt"` and `"antidrift/internal/settings"` to the import block. The `"os"` import may become unused (it was only used for `os.Getenv`) — check: `openBrowser` does not use `os`. Remove `"os"` from the import block if `go build` reports it unused. + +- [ ] **Step 2: Verify build** + +Run: `go build ./...` +Expected: success. If it reports `"os" imported and not used`, remove the `"os"` import line and rebuild. + +- [ ] **Step 3: Run the full test suite + vet** + +Run: `go test ./... && go vet ./...` +Expected: all packages PASS, vet clean. + +- [ ] **Step 4: Manual smoke test** + +Run (no env vars needed now): + +```bash +go run ./cmd/antidriftd +``` + +Expected log lines include `settings: seeded ... from environment` (first run) or `settings: ai=claude, ...`. Confirm `~/.antidrift/settings.json` exists: + +Run: `cat ~/.antidrift/settings.json` +Expected: JSON with the three keys. + +Stop the daemon (Ctrl-C). + +- [ ] **Step 5: Commit** + +```bash +git add cmd/antidriftd/main.go +git commit -m "Drive daemon config from settings file via injected applier + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 6: Settings UI — gear, overlay, browse modal + +**Files:** +- Modify: `internal/web/static/index.html` +- Modify: `internal/web/static/app.js` +- Modify: `internal/web/static/app.css` + +This task has no Go test harness (the repo has no JS tests); verification is `go build` + manual browser check. Write the exact code below. + +- [ ] **Step 1: Add the gear button and overlay container to `index.html`** + +Replace the `
` block (lines 12-15) with: + +```html +
+

AntiDrift

+
connecting…
+ +
+``` + +The overlay lives OUTSIDE `#view`, so the SSE re-render (which replaces `view.innerHTML`) never wipes it. + +- [ ] **Step 2: Repoint the knowledge "change" link in `app.js`** + +In `internal/web/static/app.js`, in `updatePlanningKnowledge` (lines 184-187), replace the prompt-based handler: + +```js + document.getElementById('knowChange').onclick = () => { + const next = prompt('Profile file path (blank = default):', k.path || ''); + if (next !== null) post('/knowledge/path', { path: next.trim() }); + }; +``` + +with: + +```js + document.getElementById('knowChange').onclick = openSettings; +``` + +- [ ] **Step 3: Append the settings overlay + browse logic to `app.js`** + +Add at the end of `internal/web/static/app.js`, AFTER the `EventSource` block: + +```js +// ---- Settings overlay ---- +function openSettings() { + fetch('/settings').then(r => r.json()).then(s => { + const ov = document.getElementById('settingsOverlay'); + ov.innerHTML = ` + `; + document.getElementById('setBackend').value = s.ai_backend || 'claude'; + document.getElementById('setMarvin').value = s.marvin_cmd || ''; + document.getElementById('setKnow').value = s.knowledge_path || ''; + ov.hidden = false; + document.getElementById('setCancel').onclick = closeSettings; + document.getElementById('setSave').onclick = saveSettings; + document.getElementById('setBrowse').onclick = () => + loadBrowse(parentDir(document.getElementById('setKnow').value)); + }); +} + +function closeSettings() { + const ov = document.getElementById('settingsOverlay'); + ov.hidden = true; + ov.innerHTML = ''; +} + +function saveSettings() { + const body = { + ai_backend: document.getElementById('setBackend').value, + marvin_cmd: document.getElementById('setMarvin').value.trim(), + knowledge_path: document.getElementById('setKnow').value.trim(), + }; + post('/settings', body).then(r => { + if (r.ok) { closeSettings(); return; } + return r.json().then(e => { + document.getElementById('setError').textContent = e.error || 'save failed'; + }); + }); +} + +function parentDir(path) { + if (!path) return ''; + return path.replace(/\/[^/]*$/, ''); +} + +function loadBrowse(dir) { + const pane = document.getElementById('browsePane'); + pane.hidden = false; + fetch('/fs/browse?dir=' + encodeURIComponent(dir || '')).then(r => { + if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'cannot read directory'); }); + return r.json(); + }).then(d => { + const items = []; + if (d.parent && d.parent !== d.dir) { + items.push(`
  • `); + } + for (const e of d.entries) { + if (e.is_dir) { + items.push(`
  • `); + } else { + items.push(`
  • `); + } + } + pane.innerHTML = `
    ${d.dir}
      ${items.join('')}
    `; + pane.querySelectorAll('button[data-dir]').forEach(b => + b.onclick = () => loadBrowse(b.getAttribute('data-dir'))); + pane.querySelectorAll('button[data-file]').forEach(b => + b.onclick = () => { + document.getElementById('setKnow').value = b.getAttribute('data-file'); + pane.hidden = true; + pane.innerHTML = ''; + }); + }).catch(err => { + pane.innerHTML = `
    ${err.message}
    `; + }); +} + +document.getElementById('gear').onclick = openSettings; +``` + +- [ ] **Step 4: Add styles to `app.css`** + +Append to `internal/web/static/app.css`: + +```css +/* Settings: header gear + overlay modal */ +.gear { + background: none; border: 0; color: var(--ink-dim); cursor: pointer; + font-size: 14px; padding: 0 4px; vertical-align: middle; +} +.gear:hover { color: var(--accent); } + +.overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.5); + display: flex; align-items: flex-start; justify-content: center; + padding: 8vh 16px; z-index: 10; +} +.overlay[hidden] { display: none; } +.modal { + width: 100%; max-width: 520px; background: var(--panel); + border: 1px solid var(--line); border-radius: 14px; padding: 20px; +} +.modal h2 { margin: 0 0 12px; font-size: 16px; } +.modal-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; } +.modal-actions .btn { margin-top: 0; } +.path-row { display: flex; gap: 8px; align-items: center; } +.path-row input { flex: 1; } +.path-row .btn { margin-top: 0; white-space: nowrap; } +.set-error { color: var(--danger); font-size: 13px; margin-top: 8px; min-height: 1em; } + +.browse { + margin-top: 10px; border: 1px solid var(--line); border-radius: 8px; + background: var(--bg); max-height: 260px; overflow-y: auto; +} +.browse[hidden] { display: none; } +.browse-dir { + padding: 8px 10px; font-size: 12px; color: var(--ink-dim); + font-family: ui-monospace, monospace; border-bottom: 1px solid var(--line); + position: sticky; top: 0; background: var(--bg); +} +.browse-list { list-style: none; margin: 0; padding: 4px 0; } +.browse-list button { + width: 100%; text-align: left; background: none; border: 0; cursor: pointer; + color: var(--ink); font: inherit; font-size: 13px; padding: 5px 12px; + font-family: ui-monospace, monospace; +} +.browse-list button:hover { background: var(--line); color: var(--accent); } +``` + +- [ ] **Step 5: Build and manual-verify in browser** + +Static assets are `//go:embed`-ed, so a rebuild is required for changes to load. + +Run: `go build ./... && go vet ./...` +Expected: success, vet clean. + +Then run the daemon and hard-reload the browser (Ctrl-Shift-R): + +```bash +go run ./cmd/antidriftd +``` + +Manual checklist (verify each): +1. A gear (⚙) shows next to the "AntiDrift" header. +2. Clicking it opens the settings overlay with backend/marvin/knowledge prefilled from `~/.antidrift/settings.json`. +3. Clicking **Browse…** lists the knowledge path's directory; clicking folders navigates, `../` goes up, clicking a `.md` file fills the path field and closes the browser pane. +4. Changing the backend to `codex` and Save closes the overlay; `cat ~/.antidrift/settings.json` shows `"ai_backend":"codex"`. +5. The planning screen's knowledge "change" link opens the same settings overlay. + +Stop the daemon (Ctrl-C). + +- [ ] **Step 6: Commit** + +```bash +git add internal/web/static/ +git commit -m "Add settings gear, overlay, and file-browser modal to the UI + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Final verification + +- [ ] Run `go build ./... && go test ./... && go vet ./...` — all green. +- [ ] Run `grep -rn "os.Getenv" cmd/ internal/` — expect no matches (all config now flows through the settings file). +- [ ] Confirm `~/.antidrift/settings.json` round-trips a UI edit (change marvin command in UI, reload page, value persists). +