diff --git a/cmd/antidriftd/main.go b/cmd/antidriftd/main.go index 1b9cc30..6f5e920 100644 --- a/cmd/antidriftd/main.go +++ b/cmd/antidriftd/main.go @@ -4,8 +4,8 @@ package main import ( "context" + "fmt" "log" - "os" "os/exec" "runtime" "time" @@ -15,6 +15,7 @@ import ( "antidrift/internal/evidence" "antidrift/internal/knowledge" "antidrift/internal/session" + "antidrift/internal/settings" "antidrift/internal/statusfile" "antidrift/internal/store" "antidrift/internal/tasks" @@ -36,33 +37,58 @@ func main() { srv := web.NewServer(ctrl) srv.Init() // re-arm or expire a restored Active session - // Wire the AI planning coach. Backend selectable via ANTIDRIFT_AI_BACKEND - // (claude default, or codex). Misconfiguration degrades to no coach rather - // than failing startup — manual planning still works. - if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil { - log.Printf("ai disabled: %v", err) - } else { + // 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) - log.Printf("ai: %s backend (coach + drift judge + nudge + reviewer)", backend.Name()) + ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd)) + ctrl.SetKnowledgePath(s.KnowledgePath) + return nil } - // Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is - // overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute - // path). A missing or failing CLI degrades to no tasks panel at fetch time — - // planning still works. - ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD"))) - log.Printf("tasks: marvin adapter (am)") - - // Wire the Knowledge port: a single profile file grounding the coach. The - // default path is overridable with ANTIDRIFT_KNOWLEDGE_FILE; unset falls back - // to ~/.antidrift/knowledge.md. A missing/unreadable file degrades to an - // ungrounded coach at load time — planning still works. - ctrl.SetKnowledge(knowledge.NewFileSource(os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"))) - log.Printf("knowledge: file adapter") + // 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) // Mirror runtime status to ~/.antidrift_status for a window-manager bar. // A misconfigured home dir degrades to no status file, not a failed start.