Generalize the focus controller into a harness hosting swappable modes

Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.

- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
  EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
  under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
  ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
  with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
  ignored, stale antidriftd binary removed.

Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 18:00:49 -04:00
parent 258de2c14b
commit 7d69a1f320
57 changed files with 4069 additions and 627 deletions
-142
View File
@@ -1,142 +0,0 @@
// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI
// and owns the commitment state machine.
package main
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"time"
"antidrift/internal/ai"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/statusfile"
"antidrift/internal/store"
"antidrift/internal/tasks"
"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
// 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"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
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.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, ctrl.State)
// Re-render the bar on every state change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
ctrl.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
ctrl.SetGuard(enforce.NewGuard())
log.Printf("enforce: window-minimize guard")
src := evidence.NewSource()
go src.Watch(context.Background(), ctrl.RecordWindow)
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)
}
}
+188
View File
@@ -0,0 +1,188 @@
// Command keeld is the Keel daemon: it serves a local web UI and drives the
// harness — the general collect→brain→act loop that hosts one mode at a time
// (focus or offscreen).
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/offscreen"
"keel/internal/settings"
"keel/internal/statusfile"
"keel/internal/tasks"
"keel/internal/web"
)
const addr = "localhost:7777"
func main() {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("resolve home dir: %v", err)
}
root := filepath.Join(home, ".keel")
// Resolve and load settings, seeding from the legacy KEEL_* 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)
}
}
// buildServices turns a Settings value into a fresh harness.Services. It
// validates the AI backend FIRST and returns an error before constructing any
// ports, so an invalid backend never half-rewires the harness. The same code
// path serves startup and live POST /settings edits.
buildServices := func(s settings.Settings) (harness.Services, error) {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
return harness.Services{
AI: svc,
Tasks: tasks.NewMarvin(s.MarvinCmd),
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
Enforce: enforce.NewGuard(),
Clock: time.Now,
}, nil
}
// Build the base services at startup. A bad persisted backend should not be
// fatal: fall back to claude (always valid), persist the correction, and retry.
base, err := buildServices(cfg)
if err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
base, err = buildServices(cfg)
if err != nil {
log.Fatalf("settings: build failed even with claude: %v", err)
}
}
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
log.Printf("enforce: window-minimize guard")
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
// mode builds its own file paths under Services.Dir.
withDir := func(dir string, f harness.Factory) harness.Factory {
return func(s harness.Services) mode.Mode {
s.Dir = dir
return f(s)
}
}
focusDir := filepath.Join(root, "modes", "focus")
offscreenDir := filepath.Join(root, "modes", "offscreen")
h := harness.New(base, map[string]harness.Factory{
"focus": withDir(focusDir, focus.Factory),
"offscreen": withDir(offscreenDir, offscreen.Factory),
})
// Re-adopt a live focus session restored from disk, if one was in flight.
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
// restored mode's async completions reach the harness's onChange listeners.
focusSvc := h.Services()
focusSvc.Dir = focusDir
if m, live, err := focus.Restore(focusSvc); err != nil {
log.Printf("focus: restore failed (continuing idle): %v", err)
} else if live {
if err := h.Adopt(m); err != nil {
log.Printf("focus: adopt restored session failed: %v", err)
} else {
log.Printf("focus: restored a live session")
}
}
srv := web.NewServer(h)
srv.Init() // re-arm or expire a restored deadline-bearing session
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
// fresh harness.Services and installs it via SetServices. Per the harness
// contract this affects the NEXT mode start; an already-active mode keeps the
// services it was built with. It validates the backend before mutating, so
// POST /settings can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
next, err := buildServices(s)
if err != nil {
return err
}
h.SetServices(next)
return nil
}
srv.SetSettings(settingsPath, cfg, applyFn)
// Mirror runtime status to ~/.keel_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, h.State)
// Re-render the bar on every harness change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
h.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
// Feed the window sensor stream into the harness, which forwards it to the
// active mode iff that mode consumes evidence. On a headless box the source is
// a no-op, so this degrades gracefully without new X11 requirements.
src := evidence.NewSource()
go src.Watch(context.Background(), h.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("keeld 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)
}
}