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
+67 -15
View File
@@ -1,5 +1,5 @@
// Package statusfile mirrors the controller's runtime status into a single-line
// file (~/.antidrift_status) so an external consumer — a window-manager status
// Package statusfile mirrors the harness's runtime status into a single-line
// file (~/.keel_status) so an external consumer — a window-manager status
// bar — can display it with a plain file read.
package statusfile
@@ -9,20 +9,22 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
"antidrift/internal/domain"
"antidrift/internal/session"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
)
// statusFileName is the file written under the user's home directory.
const statusFileName = ".antidrift_status"
const statusFileName = ".keel_status"
// interval is how often the writer re-renders. Minute granularity is enough for
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
const interval = time.Minute
// DefaultPath resolves ~/.antidrift_status.
// DefaultPath resolves ~/.keel_status.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
@@ -31,10 +33,59 @@ func DefaultPath() (string, error) {
return filepath.Join(home, statusFileName), nil
}
// Render produces the single status line for the given state. It is empty for
// Locked/idle so a bar module can hide itself. Drift status strings ("drifting")
// Render produces the single status line for the harness envelope, dispatching
// on the active mode. An idle harness ("") renders "idle"; an unknown kind
// renders a generic fallback so a future mode degrades gracefully rather than
// rendering nothing.
func Render(env mode.Envelope, now time.Time) string {
switch env.ActiveMode {
case "":
return "idle"
case "focus":
st, ok := env.Mode.(focus.State)
if !ok {
// Defensive: the envelope claims focus but carries an unexpected
// payload. Render a safe fallback instead of panicking on the assert.
return "focus"
}
return renderFocus(st, now)
case "offscreen":
return renderOffscreen(env.Mode)
default:
return env.ActiveMode
}
}
// renderOffscreen produces the off-screen status line from the mode's untyped
// View payload (a map[string]any read from the live in-process envelope). It
// shows the proposed next action when one is present, a warning glyph on error,
// and a "thinking…" placeholder otherwise. Any payload mismatch degrades to the
// placeholder rather than panicking.
func renderOffscreen(m any) string {
const thinking = "off-screen: thinking…"
view, ok := m.(map[string]any)
if !ok {
return thinking
}
status, _ := view["status"].(string)
switch status {
case "proposed":
na, _ := view["next_action"].(string)
if na = strings.TrimSpace(na); na != "" {
return "off-screen: " + na
}
return thinking
case "error":
return "off-screen: ⚠"
default:
return thinking
}
}
// renderFocus produces the focus status line. It is empty for Locked/idle focus
// states so a bar module can hide itself. Drift status strings ("drifting")
// match the JSON contract the web UI already consumes.
func Render(st session.State, now time.Time) string {
func renderFocus(st focus.State, now time.Time) string {
switch st.RuntimeState {
case domain.RuntimeActive:
timer := remaining(st, now)
@@ -58,7 +109,7 @@ func Render(st session.State, now time.Time) string {
// remaining formats the minutes left until the commitment deadline, rounded up
// so a partial minute still reads as a minute and the count hits 0m only at the
// end. It returns 0m when no deadline is known.
func remaining(st session.State, now time.Time) string {
func remaining(st focus.State, now time.Time) string {
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
return "0m"
}
@@ -70,11 +121,11 @@ func remaining(st session.State, now time.Time) string {
return fmt.Sprintf("%dm", mins)
}
// Writer periodically renders the controller state and writes it to a file,
// Writer periodically renders the harness envelope and writes it to a file,
// rewriting only when the line changes.
type Writer struct {
path string
state func() session.State
state func() mode.Envelope
now func() time.Time
wake chan struct{}
@@ -82,13 +133,14 @@ type Writer struct {
wrote bool
}
// NewWriter builds a Writer for path, reading state via the given accessor.
func NewWriter(path string, state func() session.State) *Writer {
// NewWriter builds a Writer for path, reading the harness envelope via the given
// accessor (typically harness.State).
func NewWriter(path string, state func() mode.Envelope) *Writer {
return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)}
}
// Wake asks the writer to re-render now rather than at the next tick. It is the
// hook the controller's change notifications fire through, so drift transitions
// hook the harness's change notifications fire through, so drift transitions
// reach the status bar promptly instead of lagging the web UI by up to a tick.
// The signal is coalesced (buffered, size 1): a burst of changes collapses into
// a single re-render, and the actual write still happens on the Run goroutine,
+45 -26
View File
@@ -8,17 +8,28 @@ import (
"testing"
"time"
"antidrift/internal/domain"
"antidrift/internal/session"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
)
func activeState(deadline int64, driftStatus, nudge string) session.State {
st := session.State{
// focusEnv wraps a focus.State in the harness envelope the writer now consumes.
func focusEnv(st focus.State) mode.Envelope {
return mode.Envelope{ActiveMode: "focus", Mode: st}
}
// focusState builds a focus.State with the given runtime state.
func focusState(rt domain.RuntimeState) focus.State {
return focus.State{RuntimeState: rt}
}
func activeState(deadline int64, driftStatus, nudge string) focus.State {
st := focus.State{
RuntimeState: domain.RuntimeActive,
Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline},
Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline},
}
if driftStatus != "" || nudge != "" {
st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge}
st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge}
}
return st
}
@@ -29,25 +40,33 @@ func TestRender(t *testing.T) {
cases := []struct {
name string
st session.State
env mode.Envelope
want string
}{
{"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""},
{"empty runtime", session.State{}, ""},
{"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"},
{"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"},
{"active ontask", activeState(in24m, "ontask", ""), "● 24m"},
{"active no drift view", activeState(in24m, "", ""), "● 24m"},
{"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"},
{"active nudge", activeState(in24m, "ontask", "wandered off"), " 24m ·?"},
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"},
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"},
{"active past deadline", activeState(now.Add(-5*time.Minute).Unix(), "ontask", ""), "● 0m"},
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"},
{"idle", mode.Envelope{}, "idle"},
{"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""},
{"empty focus runtime", focusEnv(focus.State{}), ""},
{"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"},
{"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"},
{"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"},
{"active no drift view", focusEnv(activeState(in24m, "", "")), " 24m"},
{"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"},
{"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), " 24m ·?"},
{"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"},
{"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"},
{"active past deadline", focusEnv(activeState(now.Add(-5*time.Minute).Unix(), "ontask", "")), "● 0m"},
{"active partial minute rounds up", focusEnv(activeState(now.Add(10*time.Second).Unix(), "ontask", "")), "● 1m"},
{"unknown kind falls back to kind name", mode.Envelope{ActiveMode: "house"}, "house"},
{"focus with wrong payload falls back", mode.Envelope{ActiveMode: "focus", Mode: "bogus"}, "focus"},
{"offscreen proposed", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "call mum"}}, "off-screen: call mum"},
{"offscreen pending", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "pending"}}, "off-screen: thinking…"},
{"offscreen error", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "error", "error": "boom"}}, "off-screen: ⚠"},
{"offscreen proposed no action", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed"}}, "off-screen: thinking…"},
{"offscreen wrong payload", mode.Envelope{ActiveMode: "offscreen", Mode: "bogus"}, "off-screen: thinking…"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Render(tc.st, now); got != tc.want {
if got := Render(tc.env, now); got != tc.want {
t.Errorf("Render() = %q, want %q", got, tc.want)
}
})
@@ -57,8 +76,8 @@ func TestRender(t *testing.T) {
func TestWriterWritesAndDedups(t *testing.T) {
path := filepath.Join(t.TempDir(), "status")
now := time.Unix(1_000_000, 0)
state := session.State{RuntimeState: domain.RuntimePlanning}
w := NewWriter(path, func() session.State { return state })
env := focusEnv(focusState(domain.RuntimePlanning))
w := NewWriter(path, func() mode.Envelope { return env })
w.now = func() time.Time { return now }
w.write()
@@ -77,7 +96,7 @@ func TestWriterWritesAndDedups(t *testing.T) {
}
// Changed state: rewrites.
state = session.State{RuntimeState: domain.RuntimeLocked}
env = focusEnv(focusState(domain.RuntimeLocked))
w.write()
if got := readFile(t, path); got != "" {
t.Errorf("changed write = %q, want empty", got)
@@ -86,8 +105,8 @@ func TestWriterWritesAndDedups(t *testing.T) {
func TestWriterRemovesFileOnCancel(t *testing.T) {
path := filepath.Join(t.TempDir(), "status")
w := NewWriter(path, func() session.State {
return session.State{RuntimeState: domain.RuntimePlanning}
w := NewWriter(path, func() mode.Envelope {
return focusEnv(focusState(domain.RuntimePlanning))
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
@@ -112,7 +131,7 @@ func TestWriterWakeRendersPromptly(t *testing.T) {
var mu sync.Mutex
st := activeState(in10m, "ontask", "")
w := NewWriter(path, func() session.State { mu.Lock(); defer mu.Unlock(); return st })
w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) })
w.now = func() time.Time { return now }
ctx, cancel := context.WithCancel(context.Background())