Files
antidrift/docs/superpowers/plans/2026-06-01-settings-page.md
T
2026-06-01 19:53:27 -04:00

31 KiB

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:

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:

// 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
git add internal/settings/
git commit -m "Add settings package: Settings file load/save/seed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

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:

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:

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:

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

	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
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 <noreply@anthropic.com>"

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:

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

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:

	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
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 <noreply@anthropic.com>"

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:

	r.POST("/knowledge/path", s.handleKnowledgePath)

and delete the knowledgePathRequest type plus the handleKnowledgePath function (currently web.go:143-159):

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:

	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
git add internal/web/
git commit -m "Remove /knowledge/path; folded into /settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

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.

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

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
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 <noreply@anthropic.com>"

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 <main id="app">…</main> block (lines 12-15) with:

<main id="app">
  <h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings"></button></h1>
  <div class="card" id="view">connecting…</div>
  <div id="settingsOverlay" class="overlay" hidden></div>
</main>

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:

  document.getElementById('knowChange').onclick = () => {
    const next = prompt('Profile file path (blank = default):', k.path || '');
    if (next !== null) post('/knowledge/path', { path: next.trim() });
  };

with:

  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:

// ---- Settings overlay ----
function openSettings() {
  fetch('/settings').then(r => r.json()).then(s => {
    const ov = document.getElementById('settingsOverlay');
    ov.innerHTML = `
      <div class="modal">
        <h2>Settings</h2>
        <label>AI backend</label>
        <select id="setBackend">
          <option value="claude">claude</option>
          <option value="codex">codex</option>
        </select>
        <label>Marvin tasks command</label>
        <input id="setMarvin" placeholder="uv run am">
        <label>Knowledge profile path</label>
        <div class="path-row">
          <input id="setKnow" placeholder="~/.antidrift/knowledge.md">
          <button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
        </div>
        <div id="browsePane" class="browse" hidden></div>
        <div id="setError" class="set-error"></div>
        <div class="modal-actions">
          <button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
          <button type="button" class="btn btn-primary" id="setSave">Save</button>
        </div>
      </div>`;
    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(`<li><button type="button" data-dir="${d.parent}">../</button></li>`);
    }
    for (const e of d.entries) {
      if (e.is_dir) {
        items.push(`<li><button type="button" data-dir="${e.path}">${e.name}/</button></li>`);
      } else {
        items.push(`<li><button type="button" data-file="${e.path}">${e.name}</button></li>`);
      }
    }
    pane.innerHTML = `<div class="browse-dir">${d.dir}</div><ul class="browse-list">${items.join('')}</ul>`;
    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 = `<div class="set-error">${err.message}</div>`;
  });
}

document.getElementById('gear').onclick = openSettings;
  • Step 4: Add styles to app.css

Append to internal/web/static/app.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):

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
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 <noreply@anthropic.com>"

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).