# Controller Refactor — Harness + Modes — 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:** Replace the focus-only `session.Controller` god-object with a generic `harness` that hosts swappable `mode`s; re-home focus as the first mode (behavior-preserving) and add off-screen mode as the second; complete the `antidrift`→`keel` rename clean-slate. **Architecture:** A thin `Mode` interface plus optional capability interfaces (`EvidenceConsumer`, `Expirer`). The `harness.Harness` owns shared `Services` (the existing ports + the relocated async primitive + change-notify) and exactly one active mode, routing evidence/expiry/commands to it. Focus is a near-mechanical extraction guarded by its existing tests; off-screen is a small one-shot collect→propose→confirm mode. **Tech Stack:** Go 1.26, gin (web), SSE broadcaster, ports-and-adapters (`ai`/`tasks`/`knowledge`/`evidence`/`enforce`). **Source spec:** `docs/superpowers/specs/2026-06-04-controller-refactor-design.md` **Build order (each phase leaves the daemon runnable):** - **Phase 0** — Rename `antidrift`→`keel` in place. Pure rename; tests stay green. - **Phase 1** — Add the `mode` contract + `harness` (additive; old controller untouched). - **Phase 2** — Migrate focus onto the harness; delete the old `session` wiring. - **Phase 3** — Add off-screen mode (new `ai.Proposer` role + `tasks` create effector). - **Phase 4** — Generalize the web/status surfaces under the state envelope. --- ## Phase 0 — Rename antidrift → keel No behavior change, no structural change. The daemon stays focus-only. Each task ends with the full suite green. ### Task 1: Rename the Go module and all import paths **Files:** - Modify: `go.mod` (module line) - Modify: all 21 `.go` files importing `antidrift/internal` - [ ] **Step 1: Rewrite the module path** Run: ```bash go mod edit -module keel grep -rl 'antidrift/internal' --include='*.go' . | xargs sed -i 's#antidrift/internal#keel/internal#g' gofmt -w . ``` - [ ] **Step 2: Verify build and tests** Run: `go build ./... && go test ./...` Expected: all packages build; all tests PASS (X11 integration tests may skip when no display — that is fine). - [ ] **Step 3: Commit** ```bash git add -A git commit -m "refactor: rename Go module antidrift -> keel" ``` ### Task 2: Rename runtime paths, status file, and env vars **Files:** - Modify: `internal/store/store.go:32,38` (`.antidrift`→`.keel`, comment) - Modify: `internal/settings/settings.go:2,20,27,33,67,71,77,78` (`.antidrift`→`.keel`, `ANTIDRIFT_*`→`KEEL_*`, comments) - Modify: `internal/statusfile/statusfile.go:2,19,25` (`.antidrift_status`→`.keel_status`, comments) - Modify: `internal/knowledge/file.go:25,49,58` (`.antidrift`→`.keel`, comments) - Modify: `internal/web/settings_handlers.go:84`, `internal/web/web.go:50` (comments only) - Test: `internal/settings/settings_test.go` (env var literals) - [ ] **Step 1: Replace the literals** Edit each file, replacing every `.antidrift` with `.keel` and every `ANTIDRIFT_` with `KEEL_`. The three env vars become `KEEL_AI_BACKEND`, `KEEL_MARVIN_CMD`, `KEEL_KNOWLEDGE_FILE`. The status-file const becomes `const statusFileName = ".keel_status"`. - [ ] **Step 2: Update the settings test env literals** In `internal/settings/settings_test.go`, replace any `ANTIDRIFT_*` env names with their `KEEL_*` equivalents so `TestSeedFromEnv` (or equivalent) still exercises the live names. - [ ] **Step 3: Verify** Run: `go test ./internal/settings/... ./internal/store/... ./internal/statusfile/... ./internal/knowledge/...` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add -A git commit -m "refactor: move runtime dir/status/env to keel (~/.keel, KEEL_*)" ``` ### Task 3: Rename the binary cmd/antidriftd → cmd/keeld **Files:** - Rename: `cmd/antidriftd/` → `cmd/keeld/` - Modify: `cmd/keeld/main.go:1,116` (package doc, listen log) - [ ] **Step 1: Move and edit** Run: ```bash git mv cmd/antidriftd cmd/keeld ``` Then in `cmd/keeld/main.go` change the leading comment `Command antidriftd is the AntiDrift focus daemon` to `Command keeld is the Keel daemon`, and the log line `antidriftd listening on http://%s` to `keeld listening on http://%s`. - [ ] **Step 2: Verify** Run: `go build ./cmd/keeld && go vet ./...` Expected: builds; no vet errors. - [ ] **Step 3: Commit** ```bash git add -A git commit -m "refactor: rename binary antidriftd -> keeld" ``` ### Task 4: Update docs to reflect the completed rename **Files:** - Modify: `AGENTS.md` (the `CLAUDE.md` symlink target) — naming-status section + `go run` command - Modify: `README.md` — `go run ./cmd/antidriftd` → `go run ./cmd/keeld` - Modify: `docs/keel-architecture.md` §1, §8 (rename now done, not deferred) - [ ] **Step 1: Edit the three docs** In `AGENTS.md`, replace the "code identity is intentionally still `antidrift`" paragraph with a note that the rename is complete (module `keel`, `keeld` binary, `~/.keel/` runtime, `KEEL_*` env). Update the `go run ./cmd/antidriftd` example to `go run ./cmd/keeld`. Do the same `go run` fix in `README.md`. In `docs/keel-architecture.md` §8, remove "Controller refactor shape" from open questions and move the rename to resolved; fix the §1 note that says only the code identity is still antidrift. - [ ] **Step 2: Commit** ```bash git add -A git commit -m "docs: mark antidrift->keel rename complete" ``` --- ## Phase 1 — The mode contract + harness (additive) New packages, fully unit-tested against a fake mode. The old `session`-based daemon keeps running unchanged; nothing wires the harness yet. ### Task 5: Define the Mode contract **Files:** - Create: `internal/mode/mode.go` - Test: (covered by harness tests in Task 9) - [ ] **Step 1: Write the contract** ```go // Package mode defines the contract the harness uses to host a single // collect→brain→act unit. Focus and off-screen are implementations. package mode import ( "context" "encoding/json" "time" "keel/internal/evidence" ) // Mode is one collect→brain→act configuration. The harness runs at most one. type Mode interface { // Kind identifies the mode ("focus", "offscreen"). Kind() string // Command routes a surface-issued command into the mode. It returns an // error for illegal/invalid commands; the web layer maps it to HTTP. Command(ctx context.Context, name string, payload json.RawMessage) error // View projects the mode's current state for surfaces (JSON-marshalable). View() any // Active reports whether the mode still holds the harness. When it returns // false the harness releases the mode and goes idle. Active() bool } // EvidenceConsumer is implemented by modes that read the window sensor stream. type EvidenceConsumer interface { OnWindow(evidence.WindowSnapshot) } // Expirer is implemented by modes with a server-authoritative deadline. Expire // takes no context to match focus's existing Expire() error method, so focus // satisfies this interface with zero changes to its tested surface. type Expirer interface { Deadline() time.Time Expire() error } // Envelope is the surfacing wrapper: the active mode's kind plus its View(). type Envelope struct { ActiveMode string `json:"active_mode"` // "" = idle Mode any `json:"mode,omitempty"` } ``` - [ ] **Step 2: Verify build** Run: `go build ./internal/mode/` Expected: builds. - [ ] **Step 3: Commit** ```bash git add internal/mode/mode.go git commit -m "feat: add mode contract (Mode, capability interfaces, Envelope)" ``` ### Task 6: Relocate the async primitive into the harness **Files:** - Create: `internal/harness/async.go` - Test: `internal/harness/async_test.go` - [ ] **Step 1: Write the failing test** ```go package harness import ( "context" "sync" "testing" "time" ) func TestAsyncRunAppliesAndNotifies(t *testing.T) { var mu sync.Mutex notified := make(chan struct{}, 1) a := NewAsync(&mu, func() { notified <- struct{}{} }) var applied bool a.Run(time.Second, func(ctx context.Context) {}, func() bool { return false }, func() { applied = true }, ) select { case <-notified: case <-time.After(time.Second): t.Fatal("notify never fired") } mu.Lock() defer mu.Unlock() if !applied { t.Fatal("apply did not run") } } func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) { var mu sync.Mutex a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") }) done := make(chan struct{}) a.Run(time.Second, func(ctx context.Context) {}, func() bool { close(done); return true }, func() { t.Fatal("apply ran on stale result") }, ) select { case <-done: case <-time.After(time.Second): t.Fatal("stale guard never evaluated") } time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/harness/ -run TestAsync` Expected: FAIL (package/`NewAsync` not defined). - [ ] **Step 3: Implement the primitive** ```go // Package harness is the generic host: it owns shared services and exactly one // active mode, and routes evidence, expiry, and commands to it. package harness import ( "context" "sync" "time" ) // Async runs generation-guarded background work for a mode. It preserves the // lock discipline of the original session.runFetchAsync: fetch runs with no // lock held; stale and apply run under the mode's mutex; notify fires once, // after the lock is released. type Async struct { mu *sync.Mutex notify func() } // NewAsync binds the helper to a mode's mutex and the harness change-notify. func NewAsync(mu *sync.Mutex, notify func()) Async { return Async{mu: mu, notify: notify} } // Run launches a background fetch. stale returns true to DISCARD the result // (a newer generation superseded it). apply records a non-stale result under // the mutex and must NOT call notify — Run owns the post-unlock notify. func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) { go func() { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() fetch(ctx) a.mu.Lock() if stale() { a.mu.Unlock() return } apply() a.mu.Unlock() if a.notify != nil { a.notify() } }() } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/harness/ -run TestAsync` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/harness/async.go internal/harness/async_test.go git commit -m "feat: relocate runFetchAsync as harness.Async" ``` ### Task 7: Define the Services handle **Files:** - Create: `internal/harness/services.go` - [ ] **Step 1: Write the Services struct** ```go package harness import ( "time" "keel/internal/ai" "keel/internal/enforce" "keel/internal/knowledge" "keel/internal/tasks" ) // Services is the shared infrastructure every mode receives from the harness. // Dir is the mode's namespaced persistence directory (~/.keel/modes/); // modes build their own file paths under it. Notify fires the harness change // listeners (web SSE + status bar). type Services struct { AI *ai.Service Tasks tasks.Provider Knowledge knowledge.Source Enforce enforce.Guard Clock func() time.Time Dir string Notify func() } ``` > Note: the spec sketched `Store *store.Scope`; the plan uses `Dir string` instead — the existing `store` functions already take explicit paths, so a base dir is the smallest thing that satisfies "mode-namespaced persistence" (YAGNI). - [ ] **Step 2: Verify build** Run: `go build ./internal/harness/` Expected: builds. - [ ] **Step 3: Commit** ```bash git add internal/harness/services.go git commit -m "feat: add harness.Services handle" ``` ### Task 8: Implement the Harness host **Files:** - Create: `internal/harness/harness.go` - [ ] **Step 1: Write the host** ```go package harness import ( "context" "encoding/json" "errors" "sync" "time" "keel/internal/evidence" "keel/internal/mode" ) var ( ErrBusy = errors.New("harness: another mode is active") ErrUnknownMode = errors.New("harness: unknown mode") ErrIdle = errors.New("harness: no active mode") ) // Factory builds a mode from the shared services, in its initial active state. type Factory func(Services) mode.Mode type Harness struct { mu sync.Mutex active mode.Mode factories map[string]Factory services Services onChange []func() } // New wires Services.Notify to the harness so every mode's async completion // reaches all listeners, then stores the services for the factories to receive. func New(services Services, factories map[string]Factory) *Harness { h := &Harness{factories: factories} services.Notify = h.notify h.services = services return h } // AddOnChange registers a change listener (web broadcaster, status writer). func (h *Harness) AddOnChange(f func()) { h.mu.Lock() h.onChange = append(h.onChange, f) h.mu.Unlock() } // SetServices swaps the services future Start calls hand to factories. In v1 it // affects the NEXT mode start only; an already-active mode keeps the services it // was built with (settings changes take effect on the next session). func (h *Harness) SetServices(s Services) { s.Notify = h.notify h.mu.Lock() h.services = s h.mu.Unlock() } func (h *Harness) notify() { h.mu.Lock() fs := append([]func(){}, h.onChange...) h.mu.Unlock() for _, f := range fs { if f != nil { f() } } } // Adopt installs an already-constructed mode as active (startup restoration). // It fails if a mode is already active. func (h *Harness) Adopt(m mode.Mode) error { h.mu.Lock() if h.active != nil { h.mu.Unlock() return ErrBusy } h.active = m h.mu.Unlock() h.notify() return nil } // Start activates a mode by kind. Idempotent if that kind is already active; // ErrBusy if a different kind is active. func (h *Harness) Start(kind string) error { h.mu.Lock() if h.active != nil { busy := h.active.Kind() != kind h.mu.Unlock() if busy { return ErrBusy } return nil } f, ok := h.factories[kind] if !ok { h.mu.Unlock() return ErrUnknownMode } h.active = f(h.services) h.mu.Unlock() h.notify() return nil } // Command routes to the active mode, then releases it if it went inactive. func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error { h.mu.Lock() m := h.active h.mu.Unlock() if m == nil { return ErrIdle } if err := m.Command(ctx, name, payload); err != nil { return err } h.releaseIfDone() h.notify() return nil } func (h *Harness) releaseIfDone() { h.mu.Lock() if h.active != nil && !h.active.Active() { h.active = nil } h.mu.Unlock() } // State returns the surfacing envelope. func (h *Harness) State() mode.Envelope { h.mu.Lock() m := h.active h.mu.Unlock() if m == nil { return mode.Envelope{} } return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()} } // RecordWindow forwards a window snapshot iff the active mode consumes evidence. func (h *Harness) RecordWindow(w evidence.WindowSnapshot) { h.mu.Lock() m := h.active h.mu.Unlock() if c, ok := m.(mode.EvidenceConsumer); ok { c.OnWindow(w) } } // Deadline returns the active mode's deadline iff it is an Expirer. func (h *Harness) Deadline() time.Time { h.mu.Lock() m := h.active h.mu.Unlock() if e, ok := m.(mode.Expirer); ok { return e.Deadline() } return time.Time{} } // Expire fires the active mode's expiry iff it is an Expirer, then releases it // if it went inactive. func (h *Harness) Expire() error { h.mu.Lock() m := h.active h.mu.Unlock() e, ok := m.(mode.Expirer) if !ok { return ErrIdle } if err := e.Expire(); err != nil { return err } h.releaseIfDone() h.notify() return nil } ``` - [ ] **Step 2: Verify build** Run: `go build ./internal/harness/` Expected: builds. - [ ] **Step 3: Commit** ```bash git add internal/harness/harness.go git commit -m "feat: implement harness host (single-active mode router)" ``` ### Task 9: Harness behavior tests with a fake mode **Files:** - Test: `internal/harness/harness_test.go` - [ ] **Step 1: Write the failing tests** ```go package harness import ( "context" "encoding/json" "sync" "testing" "time" "keel/internal/evidence" "keel/internal/mode" ) // fakeMode implements Mode and (optionally) the capability interfaces. type fakeMode struct { mu sync.Mutex kind string active bool cmds []string windows int deadline time.Time expired bool evidence bool // implements EvidenceConsumer when true expirer bool // implements Expirer when true } func (f *fakeMode) Kind() string { return f.kind } func (f *fakeMode) View() any { return map[string]bool{"active": f.active} } func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active } func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error { f.mu.Lock() defer f.mu.Unlock() f.cmds = append(f.cmds, name) if name == "finish" { f.active = false } return nil } type evidenceMode struct{ *fakeMode } func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() } type expirerMode struct{ *fakeMode } func (e expirerMode) Deadline() time.Time { return e.deadline } func (e expirerMode) Expire() error { e.mu.Lock(); e.expired = true; e.active = false; e.mu.Unlock(); return nil } func newHarness(m mode.Mode) *Harness { return New(Services{Clock: time.Now}, map[string]Factory{ m.Kind(): func(Services) mode.Mode { return m }, }) } func TestStartSingleActiveInvariant(t *testing.T) { h := New(Services{}, map[string]Factory{ "a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} }, "b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} }, }) if err := h.Start("a"); err != nil { t.Fatalf("Start a: %v", err) } if err := h.Start("a"); err != nil { t.Fatalf("re-Start a should be idempotent: %v", err) } if err := h.Start("b"); err != ErrBusy { t.Fatalf("Start b while a active = %v, want ErrBusy", err) } if got := h.State().ActiveMode; got != "a" { t.Fatalf("ActiveMode = %q, want a", got) } } func TestCommandReleasesWhenInactive(t *testing.T) { m := &fakeMode{kind: "a", active: true} h := newHarness(m) _ = h.Start("a") if err := h.Command(context.Background(), "finish", nil); err != nil { t.Fatalf("Command: %v", err) } if got := h.State().ActiveMode; got != "" { t.Fatalf("after finish ActiveMode = %q, want idle", got) } } func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) { plain := &fakeMode{kind: "plain", active: true} h := newHarness(plain) _ = h.Start("plain") h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic if plain.windows != 0 { t.Fatalf("plain mode received %d windows, want 0", plain.windows) } ev := evidenceMode{&fakeMode{kind: "ev", active: true}} h2 := newHarness(ev) _ = h2.Start("ev") h2.RecordWindow(evidence.WindowSnapshot{}) if ev.windows != 1 { t.Fatalf("evidence mode received %d windows, want 1", ev.windows) } } func TestExpiryOnlyForExpirers(t *testing.T) { plain := &fakeMode{kind: "plain", active: true} h := newHarness(plain) _ = h.Start("plain") if err := h.Expire(); err != ErrIdle { t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err) } if !h.Deadline().IsZero() { t.Fatal("non-expirer Deadline should be zero") } ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}} h2 := newHarness(ex) _ = h2.Start("ex") if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) { t.Fatalf("Deadline = %v, want unix 100", got) } if err := h2.Expire(); err != nil { t.Fatalf("Expire: %v", err) } if got := h2.State().ActiveMode; got != "" { t.Fatalf("after Expire ActiveMode = %q, want idle", got) } } func TestCommandWhileIdle(t *testing.T) { h := New(Services{}, map[string]Factory{}) if err := h.Command(context.Background(), "x", nil); err != ErrIdle { t.Fatalf("Command while idle = %v, want ErrIdle", err) } } ``` - [ ] **Step 2: Run to verify it fails, then passes** Run: `go test ./internal/harness/ -v` Expected: compiles and PASS (the host from Task 8 already satisfies these). - [ ] **Step 3: Commit** ```bash git add internal/harness/harness_test.go git commit -m "test: harness single-active, routing, capability assertion" ``` --- ## Phase 2 — Migrate focus onto the harness Move the focus implementation into `mode/focus`, adapt it to satisfy `mode.Mode`, and rewire `main`/`web` to drive the harness. The **existing focus tests move with the code and must pass unchanged** — they are the regression oracle. This phase ends with the old `session` package deleted. ### Task 10: Move domain and statemachine under focus **Files:** - Rename: `internal/domain/` → `internal/mode/focus/domain/` - Rename: `internal/statemachine/` → `internal/mode/focus/statemachine/` - Modify: every importer of `keel/internal/domain` and `keel/internal/statemachine` - [ ] **Step 1: Move and rewrite imports** Run: ```bash mkdir -p internal/mode/focus git mv internal/domain internal/mode/focus/domain git mv internal/statemachine internal/mode/focus/statemachine grep -rl 'keel/internal/domain' --include='*.go' . | xargs sed -i 's#keel/internal/domain#keel/internal/mode/focus/domain#g' grep -rl 'keel/internal/statemachine' --include='*.go' . | xargs sed -i 's#keel/internal/statemachine#keel/internal/mode/focus/statemachine#g' gofmt -w . ``` - [ ] **Step 2: Verify** Run: `go build ./... && go test ./internal/mode/focus/domain/... ./internal/mode/focus/statemachine/...` Expected: builds; PASS. (`store`, `statusfile`, `web`, `session` still import these via the new paths — that is fine for now.) - [ ] **Step 3: Commit** ```bash git add -A git commit -m "refactor: move domain + statemachine under mode/focus" ``` ### Task 11: Move the session package to mode/focus and rename the type **Files:** - Rename: `internal/session/` → `internal/mode/focus/` (merge alongside `domain/`, `statemachine/`) - Modify: package declaration `package session` → `package focus`; type `Controller` → `Mode` - [ ] **Step 1: Move the files** Run: ```bash git mv internal/session/session.go internal/mode/focus/focus.go git mv internal/session/roles.go internal/mode/focus/roles.go git mv internal/session/drift.go internal/mode/focus/drift.go git mv internal/session/stats.go internal/mode/focus/stats.go git mv internal/session/views.go internal/mode/focus/views.go git mv internal/session/session_test.go internal/mode/focus/focus_test.go ``` - [ ] **Step 2: Rename package and type** Run: ```bash sed -i 's/^package session$/package focus/' internal/mode/focus/*.go sed -i 's/\*Controller/*Mode/g; s/\bController\b/Mode/g' internal/mode/focus/*.go gofmt -w internal/mode/focus/ ``` Manually confirm `New(snapshotPath string) (*Mode, error)` and the `Mode` struct doc comment read sensibly after the rename. - [ ] **Step 3: Verify the package compiles in isolation** Run: `go build ./internal/mode/focus/` Expected: builds. (`store`/`statusfile`/`web`/`cmd` still reference `keel/internal/session` and will fail `go build ./...` until Tasks 12–14 — that is expected; build only the focus package here.) - [ ] **Step 4: Commit** ```bash git add -A git commit -m "refactor: move session -> mode/focus, Controller -> focus.Mode" ``` ### Task 12: Make focus.Mode satisfy the mode contract **Files:** - Create: `internal/mode/focus/mode.go` - Modify: `internal/mode/focus/focus.go` (use `harness.Async`; add a terminal-state notion) - [ ] **Step 1: Add the contract adapter** ```go package focus import ( "context" "encoding/json" "errors" "fmt" "time" "keel/internal/mode/focus/domain" ) // Kind identifies this mode to the harness. func (m *Mode) Kind() string { return "focus" } var errBadPayload = errors.New("focus: invalid command payload") type commitmentPayload struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxSecs int64 `json:"timebox_secs"` AllowedWindowClasses []string `json:"allowed_window_classes"` Enforce bool `json:"enforce"` } // Command maps surface command names onto the existing focus transitions. func (m *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error { switch name { case "planning": return m.EnterPlanning() case "coach": var r struct { Intent string `json:"intent"` } if err := json.Unmarshal(payload, &r); err != nil { return errBadPayload } return m.RequestCoach(r.Intent) case "commitment": var r commitmentPayload if err := json.Unmarshal(payload, &r); err != nil { return errBadPayload } level := domain.EnforcementWarn if r.Enforce { level = domain.EnforcementBlock } return m.StartManualCommitment(r.NextAction, r.SuccessCondition, time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level) case "complete": return m.Complete() case "end": return m.End() case "refocus": return m.Refocus() case "ontask": return m.OnTask() default: return fmt.Errorf("focus: unknown command %q", name) } } // View returns the focus state projection (the existing State shape). func (m *Mode) View() any { return m.State() } // Active is true from Planning through Review. End() drives the runtime back to // Locked, which the harness reads as "done" and releases. func (m *Mode) Active() bool { m.mu.Lock() defer m.mu.Unlock() return m.runtimeState != domain.RuntimeLocked } // OnWindow forwards the sensor stream to the existing evidence recorder. func (m *Mode) OnWindow(w evidence.WindowSnapshot) { m.RecordWindow(w) } ``` > `Deadline() time.Time` (`session.go:175`) and `Expire() error` (`session.go:277`) already exist on the type and satisfy `mode.Expirer` **as-is** — that is why `Expirer.Expire` takes no context. `RecordWindow` already exists (`session.go`, fed by `evidence.Source.Watch`). So the only new methods are `Kind`, `Command`, `View`, `Active`, `OnWindow`. Add the `keel/internal/evidence` import to `mode.go`. **Do not** touch focus's existing `onChanges`/`notify`/`SetOnChange`/`AddOnChange`/`Set*` methods — they stay and keep the moved tests valid. - [ ] **Step 2: Swap runFetchAsync for harness.Async** In `roles.go`, delete the `runFetchAsync` method (now `harness.Async.Run`). Add an `async harness.Async` field to the `Mode` struct and, in `New` (`focus.go`), initialize it wired to focus's **existing** `notify` fan-out: ```go m.async = harness.NewAsync(&m.mu, m.notify) ``` Then replace each `c.runFetchAsync(` call site with `c.async.Run(`; the closure bodies are unchanged. Because `m.notify` still fans out over `c.onChanges`, every test that observes via `SetOnChange`/`AddOnChange` keeps working, and the harness later registers `svc.Notify` as one of those listeners (Task 13). - [ ] **Step 3: Verify focus builds** Run: `go build ./internal/mode/focus/` Expected: builds. - [ ] **Step 4: Commit** ```bash git add internal/mode/focus/ git commit -m "feat: focus.Mode satisfies mode.Mode + capability interfaces" ``` ### Task 13: Add focus Factory/Restore (constructor + setters unchanged) **Files:** - Create: `internal/mode/focus/factory.go` The existing `New(snapshotPath string)` and the `Set*` setters (`SetClock`, `SetCoach`, `SetTasks`, `SetKnowledge`, `SetReviewer`, `SetDriftJudge`, `SetNudge`, `SetGuard`, `SetOnChange`/`AddOnChange`) stay **exactly as they are** — they are the injection API the moved tests already use, so the tests need no construction changes. The factory uses that same API. - [ ] **Step 1: Add the factory + restore helpers** ```go package focus import ( "path/filepath" "keel/internal/harness" "keel/internal/mode" ) // build constructs a focus Mode under the mode's namespaced dir and injects the // shared services through the existing setters (the same API tests use). func build(svc harness.Services) (*Mode, error) { m, err := New(filepath.Join(svc.Dir, "state.json")) if err != nil { return nil, err } if svc.Clock != nil { m.SetClock(svc.Clock) } m.SetCoach(svc.AI) m.SetReviewer(svc.AI) m.SetDriftJudge(svc.AI) m.SetNudge(svc.AI) m.SetTasks(svc.Tasks) m.SetKnowledge(svc.Knowledge) m.SetGuard(svc.Enforce) m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener return m, nil } // Factory builds a fresh focus mode entering Planning (used by harness.Start). func Factory(svc harness.Services) mode.Mode { m, err := build(svc) if err != nil { m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works m.SetOnChange(svc.Notify) } _ = m.EnterPlanning() return m } // Restore loads a persisted focus session and reports whether one was live // (Planning/Active/Transition/Review). Used at startup to re-adopt a session. func Restore(svc harness.Services) (*Mode, bool, error) { m, err := build(svc) if err != nil { return nil, false, err } return m, m.Active(), nil } ``` > `svc.AI` is `*ai.Service`, which implements `Coach`/`Reviewer`/`DriftJudge`/`Nudger` — pass it to each role setter. Confirm the exact setter names against `roles.go`/`drift.go` when executing; the list above matches the methods read in Phase 0. - [ ] **Step 2: Verify** Run: `go build ./internal/mode/focus/` Expected: builds. - [ ] **Step 3: Commit** ```bash git add internal/mode/focus/factory.go git commit -m "feat: focus Factory/Restore inject services via existing setters" ``` ### Task 14: Rewire web to the harness **Files:** - Modify: `internal/web/web.go` (server holds `*harness.Harness`; generalized routes) - Modify: `internal/web/broadcaster.go` (unchanged mechanism; verify imports) - Test: `internal/web/web_test.go` (retarget to harness) - [ ] **Step 1: Replace the controller field and routes** In `web.go`, change `ctrl *session.Controller` to `h *harness.Harness`. Replace the focus-specific routes with the generalized pair plus the kept SSE/settings routes: ```go r.GET("/events", s.handleEvents) r.POST("/mode/:kind/start", s.handleStart) r.POST("/mode/command/:name", s.handleCommand) r.GET("/settings", s.handleGetSettings) r.POST("/settings", s.handlePostSettings) r.GET("/fs/browse", s.handleBrowse) ``` ```go func (s *Server) handleStart(c *gin.Context) { kind := c.Param("kind") err := s.h.Start(kind) if err == nil && kind == "focus" { s.armExpiry() } s.respond(c, err) } func (s *Server) handleCommand(c *gin.Context) { name := c.Param("name") body, _ := io.ReadAll(c.Request.Body) err := s.h.Command(c.Request.Context(), name, body) switch name { case "commitment": if err == nil { s.armExpiry() } case "complete", "end": s.cancelExpiry() } s.respond(c, err) } ``` `stateJSON` marshals `s.h.State()`. `armExpiry`/`cancelExpiry`/`Init` use `s.h.Deadline()` and `s.h.Expire(context.Background())` instead of the controller methods. `respond` maps `harness.ErrBusy`/`ErrUnknownMode`/`ErrIdle` and `statemachine.IllegalTransitionError` to 409/400 as today. - [ ] **Step 2: Update the web test to drive the harness** In `web_test.go`, construct a harness with the focus factory (or a fake mode) instead of a raw controller, and update endpoint URLs to `/mode/focus/start` and `/mode/command/commitment` etc. Keep assertions on the resulting envelope JSON (`active_mode`, `mode`). - [ ] **Step 3: Verify** Run: `go build ./internal/web/ && go test ./internal/web/` Expected: builds; PASS. - [ ] **Step 4: Commit** ```bash git add internal/web/ git commit -m "refactor: web server drives the harness via generalized routes" ``` ### Task 15: Rewire statusfile and main; delete the old session package **Files:** - Modify: `internal/statusfile/statusfile.go` (read `mode.Envelope` instead of `session.State`) - Modify: `cmd/keeld/main.go` (build Services, harness, register focus, restore) - Delete: `internal/session/` (now empty after Task 11 moves) - [ ] **Step 1: Retarget statusfile** Change the writer to accept `func() mode.Envelope` and render focus's line from `env.Mode` when `env.ActiveMode == "focus"`, an idle line when `env.ActiveMode == ""`. Update `statusfile_test.go` accordingly. - [ ] **Step 2: Rebuild main around the harness** ```go func main() { home, _ := os.UserHomeDir() root := filepath.Join(home, ".keel") cfg := loadSettings() // existing settings load/seed, now under ~/.keel backend, err := ai.NewBackend(cfg.AIBackend) if err != nil { log.Fatalf("ai backend: %v", err) } svc := ai.NewService(backend) base := harness.Services{ AI: svc, Tasks: tasks.NewMarvin(cfg.MarvinCmd), Knowledge: knowledge.NewFileSource(cfg.KnowledgePath), Enforce: enforce.NewGuard(), Clock: time.Now, } h := harness.New(base, map[string]harness.Factory{ "focus": focus.Factory, "offscreen": offscreen.Factory, // added in Phase 3 }) // Restore a live focus session, if any. focusSvc := base focusSvc.Dir = filepath.Join(root, "modes", "focus") if m, live, err := focus.Restore(focusSvc); err == nil && live { _ = h.Adopt(m) } srv := web.NewServer(h) srv.Init() // ... statusfile writer (h.State), evidence source (h.RecordWindow), settings apply, listen ... } ``` > Per-mode `Dir` is injected by the factory wrapper: register `focus.Factory` wrapped so it sets `svc.Dir = root/modes/focus` before constructing. Do the same for offscreen (`root/modes/offscreen`): > ```go > withDir := func(dir string, f harness.Factory) harness.Factory { > return func(svc harness.Services) mode.Mode { svc.Dir = dir; return f(svc) } > } > // "focus": withDir(filepath.Join(root,"modes","focus"), focus.Factory), etc. > ``` > The settings `applyFn` rebuilds the AI/tasks/knowledge ports into a fresh `harness.Services` and calls `h.SetServices(...)` (added in Task 8); per its documented v1 semantics, that affects the next mode start, not the already-active mode. - [ ] **Step 3: Delete the empty session dir and verify the whole build** Run: ```bash rmdir internal/session 2>/dev/null || git rm -r internal/session go build ./... && go test ./... ``` Expected: whole module builds; **all focus tests pass unchanged** (the regression oracle); harness/web/statusfile tests pass. - [ ] **Step 4: Commit** ```bash git add -A git commit -m "refactor: wire keeld through the harness; remove session package" ``` ### Task 16: Manual smoke test of focus parity - [ ] **Step 1: Run the daemon and exercise focus end to end** Run: `go run ./cmd/keeld` Then in the browser at `http://localhost:7777`: start focus → plan → coach → commit (with a timebox) → observe evidence/drift → complete → review → end. Confirm behavior matches pre-refactor focus. - [ ] **Step 2: Confirm the runtime moved** Run: `ls ~/.keel/modes/focus/` Expected: `state.json`, `audit.jsonl`, `sessions/` after a completed session. --- ## Phase 3 — Off-screen mode A one-shot collect→propose→confirm mode. New `ai.Proposer` role, new `tasks` create effector, new `mode/offscreen` package. ### Task 17: Add the tasks.Create effector **Files:** - Modify: `internal/tasks/tasks.go` (extend `Provider`; add `NewTask`) - Modify: `internal/tasks/marvin.go` (implement `Create`) - Test: `internal/tasks/marvin_test.go` - [ ] **Step 1: Write the failing test** ```go func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) { var gotArgs []string m := &Marvin{run: func(ctx context.Context, args ...string) ([]byte, error) { gotArgs = args return []byte(`{"id":"task-1"}`), nil }} err := m.Create(context.Background(), tasks.NewTask("buy groceries")) if err != nil { t.Fatalf("Create: %v", err) } // add subcommand present and title passed through joined := strings.Join(gotArgs, " ") if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") { t.Fatalf("args = %v, want marvin add with title", gotArgs) } } ``` > Match the test to `Marvin`'s actual runner field/shape (see `marvin.go:41-53`); the example assumes a `run` func field — adapt to the real one. - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/tasks/ -run TestCreate` Expected: FAIL (`Create`/`NewTask` undefined). - [ ] **Step 3: Implement** In `tasks.go`: ```go type Provider interface { Today(ctx context.Context) ([]Task, error) Create(ctx context.Context, t Task) error } // NewTask builds a minimal task for creation (title only in v1). func NewTask(title string) Task { return Task{Title: title} } ``` In `marvin.go`, implement `Create` by invoking the Marvin CLI's add path, preserving the `fieldUpdates` CRDT semantics the read path relies on (mirror how `Today` shells out). Return the CLI error on failure. - [ ] **Step 4: Verify** Run: `go test ./internal/tasks/` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/tasks/ git commit -m "feat: tasks.Provider.Create effector (Marvin add, CRDT-preserving)" ``` ### Task 18: Add the ai.Proposer role **Files:** - Create: `internal/ai/proposer.go` - Test: `internal/ai/proposer_test.go` - [ ] **Step 1: Write the failing test** ```go package ai import ( "context" "testing" ) func TestProposeParsesProposal(t *testing.T) { svc := NewService(fakeBackend(`{"next_action":"call mum","rationale":"people goal"}`)) p, err := svc.Propose(context.Background(), "off-screen brief") if err != nil { t.Fatalf("Propose: %v", err) } if p.NextAction != "call mum" || p.Rationale != "people goal" { t.Fatalf("proposal = %+v", p) } } ``` > `fakeBackend` already exists in the ai test files (used by coach/verdict tests) — reuse it; adapt the constructor name if it differs. - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/ai/ -run TestPropose` Expected: FAIL (`Propose`/`Proposal` undefined). - [ ] **Step 3: Implement** ```go package ai import "context" // OffscreenProposal is the off-screen brain output. type OffscreenProposal struct { NextAction string `json:"next_action"` Rationale string `json:"rationale"` } // Proposer turns an off-screen brief into a single suggested action. type Proposer interface { Propose(ctx context.Context, brief string) (OffscreenProposal, error) } func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) { // Mirror Service.Coach: build a prompt from brief, call s.backend, parse JSON. // Reuse the existing JSON-extraction + ErrEmptyResponse/ErrNoJSON handling. } ``` > Use the existing `Proposal` errors and JSON helpers from `proposal.go`/`coach.go`. If the name `Proposal` already exists for focus's coach output, name the off-screen type `OffscreenProposal` (as above) to avoid collision; update the test accordingly. - [ ] **Step 4: Verify** Run: `go test ./internal/ai/` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/ai/ git commit -m "feat: ai.Proposer role for off-screen briefs" ``` ### Task 19: Implement off-screen mode **Files:** - Create: `internal/mode/offscreen/offscreen.go` - Create: `internal/mode/offscreen/collect.go` (brief assembly) - Test: `internal/mode/offscreen/offscreen_test.go` - [ ] **Step 1: Write the failing test** ```go package offscreen import ( "context" "encoding/json" "sync" "testing" "keel/internal/ai" "keel/internal/harness" "keel/internal/tasks" ) type fakeTasks struct { created []string } func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil } func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error { f.created = append(f.created, t.Title) return nil } func newTestMode(t *testing.T, ft *fakeTasks) *Mode { svc := harness.Services{ AI: ai.NewService(/* fake backend returning a proposal */), Tasks: ft, Clock: func() time.Time { return time.Unix(0, 0) }, Notify: func() {}, } return New(svc) } func TestConfirmFilesExactlyOneTask(t *testing.T) { ft := &fakeTasks{} m := newTestMode(t, ft) // drive: start -> wait proposed -> confirm _ = m.Command(context.Background(), "start", nil) waitProposed(t, m) if err := m.Command(context.Background(), "confirm", nil); err != nil { t.Fatalf("confirm: %v", err) } if len(ft.created) != 1 { t.Fatalf("created %d tasks, want 1", len(ft.created)) } if m.Active() { t.Fatal("mode should be done after confirm") } } func TestDismissWritesNothing(t *testing.T) { ft := &fakeTasks{} m := newTestMode(t, ft) _ = m.Command(context.Background(), "start", nil) waitProposed(t, m) _ = m.Command(context.Background(), "dismiss", nil) if len(ft.created) != 0 { t.Fatalf("dismiss created %d tasks, want 0", len(ft.created)) } if m.Active() { t.Fatal("mode should be done after dismiss") } } ``` > `waitProposed` polls `m.View()` until status is `proposed` (the async brain call). Provide a fake backend that returns a fixed proposal JSON (reuse the ai test helper pattern). - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/mode/offscreen/` Expected: FAIL (package undefined). - [ ] **Step 3: Implement the mode** ```go // Package offscreen is the away-from-the-desk mode: it proposes one worthwhile // off-screen action from the life-domain frame and files it on confirm. package offscreen import ( "context" "encoding/json" "errors" "sync" "time" "keel/internal/ai" "keel/internal/harness" "keel/internal/mode" "keel/internal/tasks" ) const proposeTimeout = 60 * time.Second const ( statusPending = "pending" statusProposed = "proposed" statusError = "error" statusDone = "done" ) type Mode struct { mu sync.Mutex async harness.Async ai *ai.Service tasks tasks.Provider knowle knowledge.Source dir string clock func() time.Time status string gen int proposal *ai.OffscreenProposal errMsg string done bool } func New(svc harness.Services) *Mode { m := &Mode{ ai: svc.AI, tasks: svc.Tasks, knowle: svc.Knowledge, dir: svc.Dir, clock: svc.Clock, status: statusPending, } m.async = harness.NewAsync(&m.mu, svc.Notify) return m } func (m *Mode) Kind() string { return "offscreen" } func (m *Mode) Active() bool { m.mu.Lock() defer m.mu.Unlock() return !m.done } func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error { switch name { case "start": return m.start() case "confirm": return m.confirm(ctx) case "dismiss": m.mu.Lock(); m.done = true; m.mu.Unlock() return nil default: return errors.New("offscreen: unknown command " + name) } } func (m *Mode) start() error { m.mu.Lock() m.gen++ gen := m.gen m.status = statusPending m.mu.Unlock() brief := m.assembleBrief() // collect.go: tasks.Today + goals + life-bugs var p ai.OffscreenProposal var err error m.async.Run(proposeTimeout, func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) }, func() bool { return gen != m.gen }, func() { if err != nil { m.status = statusError m.errMsg = err.Error() return } m.status = statusProposed m.proposal = &p }) return nil } func (m *Mode) confirm(ctx context.Context) error { m.mu.Lock() p := m.proposal m.mu.Unlock() if p == nil { return errors.New("offscreen: nothing to confirm") } if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil { return err } m.mu.Lock() m.status = statusDone m.done = true m.mu.Unlock() return nil } // View projects the proposal card. func (m *Mode) View() any { m.mu.Lock() defer m.mu.Unlock() v := map[string]any{"status": m.status} if m.proposal != nil { v["next_action"] = m.proposal.NextAction v["rationale"] = m.proposal.Rationale } if m.errMsg != "" { v["error"] = m.errMsg } return v } // Factory builds a fresh off-screen mode and kicks off the brief immediately. func Factory(svc harness.Services) mode.Mode { m := New(svc) _ = m.start() return m } ``` In `collect.go`, implement `assembleBrief()`: call `m.tasks.Today(ctx)` (best-effort), load `~/owc/goals-2026.md` via `m.knowle.Load`, glob the curated life-bug files (`filepath.Glob` over `~/owc/resources/bug-{physical-reset,workout-effort,nutrition-effort,missing-social-life,make-environment-beautiful,marriage-strategy}.md`), and concatenate into a budgeted string. Add the `knowledge` import to `offscreen.go`. - [ ] **Step 4: Verify** Run: `go test ./internal/mode/offscreen/` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/mode/offscreen/ git commit -m "feat: off-screen mode (collect->propose->confirm)" ``` ### Task 20: Register off-screen in main and smoke test **Files:** - Modify: `cmd/keeld/main.go` (factory already referenced in Task 15; confirm the import + per-mode Dir wrapper) - [ ] **Step 1: Confirm registration and Dir wiring** Ensure `"offscreen": offscreen.Factory` is registered with its `Dir = root/modes/offscreen` wrapper, and the `offscreen` package is imported. - [ ] **Step 2: Verify and smoke test** Run: `go build ./... && go test ./...` Expected: green. Then `go run ./cmd/keeld`, and via curl/browser: `POST /mode/offscreen/start`, observe a `proposed` envelope, `POST /mode/command/confirm`, confirm a Marvin task is filed and the harness returns to idle. - [ ] **Step 3: Commit** ```bash git add -A git commit -m "feat: register off-screen mode in keeld" ``` --- ## Phase 4 — Surfaces under the envelope The Go side already emits `mode.Envelope`. This phase reshapes the frontend to route on `active_mode` and adds the launcher + off-screen view. ### Task 21: Frontend mode router + launcher **Files:** - Modify: `internal/web/static/app.js` - Modify: `internal/web/static/index.html` - Modify: `internal/web/static/app.css` - [ ] **Step 1: Route on active_mode** In `app.js`, the SSE handler now receives `{active_mode, mode}`. Branch: - `active_mode === ""` → render the launcher (two buttons: Start focus → `POST /mode/focus/start`; Start off-screen → `POST /mode/offscreen/start`). - `active_mode === "focus"` → render the existing focus screens, fed by `mode` (the former top-level `State`). Re-point every existing `fetch('/planning'|'/coach'|'/commitment'|'/complete'|'/end'|'/refocus'|'/ontask')` call to `POST /mode/command/` (and the planning entry to `POST /mode/focus/start`). - `active_mode === "offscreen"` → render the off-screen card (Task 22). - [ ] **Step 2: Manual verify focus parity through the new router** Run: `go run ./cmd/keeld`, exercise the full focus flow from the launcher. Confirm identical behavior. - [ ] **Step 3: Commit** ```bash git add internal/web/static/ git commit -m "feat: frontend routes on active_mode; adds mode launcher" ``` ### Task 22: Off-screen proposal card **Files:** - Modify: `internal/web/static/app.js`, `index.html`, `app.css` - [ ] **Step 1: Render the card** When `active_mode === "offscreen"`, render from `mode`: `status === "pending"` → a spinner; `"proposed"` → the `next_action` + `rationale` with **Do it** (`POST /mode/command/confirm`) and **Dismiss** (`POST /mode/command/dismiss`); `"error"` → the error with a retry (`POST /mode/offscreen/start`). Phone-first layout (single column, large tap targets). - [ ] **Step 2: Manual verify on a phone-width viewport** Run the daemon; in devtools responsive mode, start off-screen, confirm the card renders and **Do it** files a task and returns to the launcher. - [ ] **Step 3: Commit** ```bash git add internal/web/static/ git commit -m "feat: off-screen proposal card (phone-first)" ``` ### Task 23: Status bar under the envelope **Files:** - Modify: `internal/statusfile/statusfile.go` (already retargeted in Task 15 — finalize the off-screen line) - Test: `internal/statusfile/statusfile_test.go` - [ ] **Step 1: Render one line per active mode** `active_mode === "focus"` → today's drift/status line (unchanged content). `"offscreen"` → e.g. `off-screen: ` when proposed, else `off-screen: thinking…`. `""` → `idle`. Add/adjust a test per branch. - [ ] **Step 2: Verify** Run: `go test ./internal/statusfile/` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add internal/statusfile/ git commit -m "feat: status bar renders per active mode" ``` ### Task 24: Final docs + external-config note **Files:** - Modify: `docs/keel-architecture.md` §5, §10 (off-screen mode with real collectors; drop the phantom "house mode") - Modify: `README.md` (mention the two modes + launcher) - [ ] **Step 1: Fix the architecture doc and README** Rewrite §5 ("One loop, concrete") and §10 ("Smallest real slice") around off-screen mode using the real collectors (`goals-2026.md` + life-domain `bug-*.md` + Marvin), removing the `house-integrity` goal and the solved house bug. Note off-screen memory (AW buckets) as the next increment. - [ ] **Step 2: Commit** ```bash git add -A git commit -m "docs: describe off-screen mode; retire the house-mode phantom" ``` - [ ] **Step 3: External config (manual, outside the repo)** Felix updates by hand and confirms: - WM status-bar config: read `~/.keel_status` (was `~/.antidrift_status`). - Any systemd unit / shell alias / launcher: invoke `keeld` (was `antidriftd`). --- ## Self-review notes - **Spec coverage:** Mode contract (T5), harness + Services + Async (T6–T9), focus extraction + Locked→idle + Expirer/EvidenceConsumer (T10–T16), off-screen collect→propose→gated-confirm (T17–T20), envelope surfacing + launcher + status bar (T21–T23), rename in-repo + external note (T1–T4, T24), clean-slate persistence via per-mode `Dir` (T7, T15). Non-goals (AW memory, concurrent modes) are not implemented, as intended. - **Regression oracle:** the focus tests (`session_test.go`, internal `package session` → `package focus`) keep using `New(path)` + the `Set*` setters, both preserved unchanged (T11–T13); they must pass after rewiring (T15) — the proof the extraction preserved behavior. The `Expirer` interface deliberately takes no context so focus's existing `Expire() error` satisfies it untouched. - **Known adaptation points flagged inline (resolve against real code when executing):** the Marvin CLI runner field shape (T17), reuse of the existing ai JSON-extraction/error helpers in `Propose` (T18), and reconstructing the web test around a harness + focus factory (T14).