# Controller Refactor — Harness + Modes — Design **Date:** 2026-06-04 **Status:** Approved, ready for implementation planning **Scope:** Rewrite the focus-only session controller into a generic **harness** that hosts swappable **modes**. Focus becomes the first mode (behavior-preserving extraction); **off-screen mode** becomes the second (new, the proof the abstraction generalizes). The `antidrift`→`keel` code rename and `~/.antidrift/`→`~/.keel/` runtime move land in the same pass, clean-slate (no state migration). ## Problem `session.Controller` (≈1,400 LOC across `session.go`, `roles.go`, `drift.go`, `stats.go`, `views.go`) is a god-object. It conflates three things: 1. **Generic runtime orchestration** — holding the ports, the async brain-call primitive (`runFetchAsync`), persistence, change-notify, the evidence ingestion callback, the clock. 2. **The focus state machine** — `locked→planning→active→transition→review`, commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence stats, reflection, session sealing. 3. **The focus domain payload** — `domain.Commitment` (`next_action / success_condition / timebox`), sitting in a package named `domain` as if it were generic. Focus mode is not *a* mode — it **is** the controller. There is no seam to add a second mode. Keel's thesis (the harness is the product; focus is one mode among many) cannot be expressed until (1) is separated from (2)+(3). The directory and git repo are already `keel`; only the code identity still says `antidrift`. AntiDrift was never in production and its `~/.antidrift/` ledger is disposable, so the rename and runtime move can be done outright with no migration ceremony. ## Goals - A generic `internal/harness` host that owns shared services and exactly one active mode, and routes I/O to it. - A small `Mode` contract that holds both a long-lived stateful mode (focus) and a one-shot request/response mode (off-screen) without crippling either. - Focus re-homed as `mode/focus` with **identical behavior** — existing focus tests are the regression oracle. - Off-screen mode built end-to-end: collect → assemble → brain → surface → gated Marvin write. - The `antidrift`→`keel` rename complete in code and runtime, clean-slate. ## Non-goals (YAGNI) - **AW-bucket memory** (the §7 "remember across runs" layer). Off-screen v1 is stateless. AW memory is the immediate fast-follow, its own spec. - **Concurrent / ambient modes.** Exactly one active mode at a time (idle ↔ focus *or* off-screen). Matches today's single-commitment reality. - **Any third mode.** - **State migration** from `~/.antidrift/`. Clean slate by decision. - Renaming the off-screen mode's working code name (`offscreen`) — placeholder, rename freely later. ## Decisions that shaped this design - **One thin `Mode` interface + capability interfaces**, not a stateless collect/assemble/effector pipeline framework (Procrustean for focus's stateful machine, and a YAGNI framework for two modes), and not two formal mode *kinds* (fragments the concept before the seams are felt). Capability interfaces (`EvidenceConsumer`, `Expirer`) give the heterogeneity honesty without the taxonomy. If a third one-shot mode appears, off-screen's flow can be promoted into a real pipeline helper then — driven by evidence. - **Generalize the web routes** (`/mode/{kind}/start`, `/mode/command/{name}`) rather than keep focus's bespoke endpoints. The frontend is rewritten under the state envelope regardless. - **`Locked` collapses into harness idle.** "No active mode" is the resting state; focus is `Planning→Active→Review→done`. One fewer state. - **Off-screen reuses the existing single-file `knowledge.Source`** for the `~/owc/goals-2026.md` frame, plus a small in-package glob read (`filepath.Glob` + read) for the curated life-bug set. `knowledge.Source.Load` is single-path by contract; no new adapter package — smallest real slice. - **The confirm click is the effector gate.** No auto-write in v1. ## Package layout ``` cmd/keeld/main.go # wires harness + modes + surfaces + settings internal/harness/ harness.go # Harness: services, the one active mode, change-notify, store root services.go # Services handle passed to modes async.go # generation-guarded async primitive (was runFetchAsync) internal/mode/ mode.go # Mode interface, capability interfaces, Envelope internal/mode/focus/ # today's session/* + domain/* + statemachine/* re-homed focus.go statemachine.go drift.go stats.go views.go roles.go ... internal/mode/offscreen/ offscreen.go # NEW: off-screen mode internal/ai/ tasks/ knowledge/ evidence/ enforce/ # shared ports — only module path changes internal/store/ # persistence primitives + per-mode namespacing internal/settings/ statusfile/ web/ # web becomes mode-aware ``` `internal/domain` dissolves into `mode/focus`: `Commitment`, `RuntimeState`, `EnforcementLevel`, `AllowedContext`, `PolicySnapshot` are all focus concepts. `internal/statemachine` likewise moves under `mode/focus` (its states are the focus lifecycle). Off-screen touches none of them. ## The Mode contract ```go package mode // Mode is one collect→brain→act configuration. The harness runs at most one. type Mode interface { Kind() string // "focus", "offscreen" Command(ctx context.Context, name string, payload json.RawMessage) error View() any // JSON-marshalable projection Active() bool // false ⇒ harness returns to idle } // Optional capabilities — the harness asserts for these. type EvidenceConsumer interface { OnWindow(evidence.WindowSnapshot) } // focus only type Expirer interface { Deadline() time.Time; Expire(context.Context) error } // focus only ``` `Command` returns an error for illegal/invalid commands; the web layer maps it to HTTP (today's `IllegalTransitionError`→409 mapping moves with focus). `View` returns the mode's own shape, wrapped by the harness in the envelope below. ## The Harness + Services ```go type Harness struct { mu sync.Mutex active mode.Mode // nil = idle factories map[string]func(Services) mode.Mode // "focus", "offscreen" services Services onChange []func() } func (h *Harness) Start(kind string) error // idle → mode (single-active invariant) func (h *Harness) Command(ctx, name, payload) error // routes to active; cleans up active→done func (h *Harness) State() mode.Envelope func (h *Harness) RecordWindow(w evidence.WindowSnapshot) // forwards iff active is EvidenceConsumer func (h *Harness) Deadline() time.Time // delegates iff active is Expirer func (h *Harness) Expire(ctx) error // delegates iff active is Expirer ``` After any `Command`/`Start`, if `active.Active()` is false the harness clears `active` to nil (idle). `Start` on a non-idle harness is an error unless the requested kind is already active. `Services` is what a mode receives — the ports plus relocated infrastructure: ```go type Services struct { AI *ai.Service // coach/judge/nudge/reviewer + new proposer role Tasks tasks.Provider // read (Today) + new write (Create) Knowledge knowledge.Source Enforce enforce.Guard Clock func() time.Time Store *store.Scope // mode-namespaced (~/.keel/modes//) Notify func() // fires harness change-listeners (SSE + status bar) } ``` ### The async primitive `runFetchAsync` (today `session/roles.go:99`) moves to `harness/async.go` as a helper parameterized by **the mode's own mutex** plus `Services.Notify`: ```go type Async struct { mu *sync.Mutex; notify func() } func (a Async) Run(timeout time.Duration, fetch func(context.Context), stale func() bool, apply func()) ``` This preserves today's exact lock discipline: `fetch` runs with no lock held; `stale`/`apply` run under the mode's mutex; `notify` fires once, after unlock. Each mode constructs one `Async{mu: &m.mu, notify: svc.Notify}`. Generation counters and status enums stay inside each mode, exactly as today. ## Focus mode (behavior-preserving extraction) `session.Controller` minus the host plumbing becomes `focus.Mode`. The state machine, drift/nudge/enforcement, evidence stats, reflection, and the planning coach/tasks/knowledge fetches move verbatim. Focus implements `Mode`, `EvidenceConsumer` (today's `RecordWindow`), and `Expirer` (today's deadline/`Expire`). Its `View()` returns today's `session.State` shape, unchanged, so the existing focus screens render identically. Command mapping (today's routes → focus `Command` names): `planning`, `coach`, `commitment`, `complete`, `end`, `refocus`, `ontask`. `Locked` is removed: harness idle replaces it. `Planning→Active→Review→done`, then the harness returns to idle. `admin_override` stays inside focus (an enforcement-escape concern). **Existing focus tests move with the package and must pass unchanged** — they are the proof the extraction preserved behavior. ## Off-screen mode (the proof) The away-from-the-desk complement to focus. A one-shot request/response lifecycle — this is what proves the abstraction holds for a mode that is *not* a long-lived session. ``` start → assembling → proposed → (confirm | dismiss) → done ``` - **`Command "start"`** — collect + assemble + brain via the async primitive; view status `pending`. - **Collect (real frame elements):** - `tasks.Provider.Today()` — life / non-work items. - `~/owc/goals-2026.md` (Body/Spirit, People, Via Negativa) via the existing single-file `knowledge.Source`. - `~/owc` life-domain bugs via a small in-package glob read: `physical-reset`, `workout-effort`, `nutrition-effort`, `missing-social-life`, `make-environment-beautiful`, `marriage-strategy`. - **Brain:** a new thin `ai.Proposer` role — `Propose(ctx, brief) → Proposal{NextAction, Rationale}` — reusing `ai.Backend`. A sibling to `Coach`, not a bend of it. - **Surface:** phone-friendly proposal card (off-screen ⇒ you're not at the desk; the phone surface earns its keep here). - **`Command "confirm"` (gated effector):** a **new** `tasks.Provider.Create(ctx, Task)` files the proposed task in Marvin, preserving the `fieldUpdates` CRDT on write. The confirm click *is* the gate. - **`Command "dismiss"`:** done, nothing written. - **Memory:** none in v1 (AW buckets deferred). Off-screen implements only `Mode` (no evidence stream, no timebox), so it exercises the capability-assertion path: the harness forwards neither window events nor an expiry timer to it. ## Surfacing ```go package mode type Envelope struct { ActiveMode string `json:"active_mode"` // "" = idle Mode any `json:"mode,omitempty"` // active mode's View() } ``` Web routes generalize: - `POST /mode/{kind}/start` → `harness.Start` - `POST /mode/command/{name}` → `harness.Command` (request body = payload) - `GET /events` → SSE of the envelope (broadcaster mechanism unchanged) - The server-owned expiry timer arms from `harness.Deadline()` and fires `harness.Expire()` via the `Expirer` assertion (today's `web.armExpiry` logic, retargeted). `app.js` switches on `active_mode`: `""` → launcher (pick focus / off-screen); `"focus"` → today's screens, fed by `.mode`; `"offscreen"` → the proposal card. The status-file writer renders one line per active mode (focus's drift line, off-screen's proposal summary, or `idle`). **This is the largest single chunk of new work**: the focus UI is reshaped under the envelope and a small off-screen view is added. ## Persistence (clean slate) - Root `~/.keel/`; settings `~/.keel/settings.json`; status `~/.keel_status`. - Per-mode namespace via `store.Scope`: `~/.keel/modes/focus/{state.json, audit.jsonl, sessions/}`. - Off-screen v1 persists nothing (ephemeral; restart = idle). - **No migration** from `~/.antidrift/`. ## The rename (lands in this pass) In-repo: - `go.mod` module `antidrift`→`keel`; all import paths. - `cmd/antidriftd`→`cmd/keeld`; binary `keeld`; log strings. - `store`/`settings`/`statusfile` default paths → `~/.keel*`. - `settings.SeedFromEnv` `ANTIDRIFT_*`→`KEEL_*`. - `CLAUDE.md` naming-status section (rename now *done*). - `keel-architecture.md`: §1/§8 (rename resolved), §5/§10 (remove the phantom "house mode"; describe off-screen mode with the real collectors above). External — Felix updates by hand (out of repo, listed in the plan): - WM status-bar config reading `~/.antidrift_status` → `~/.keel_status`. - Any systemd unit / shell alias / launcher invoking `antidriftd` → `keeld`. ## Testing - **Focus:** existing tests relocate with the package (import paths only) and must pass unchanged — the regression oracle for the extraction. - **Harness:** new unit tests with a fake mode — single-active invariant, command routing, capability assertion (window events only to `EvidenceConsumer`s; expiry only to `Expirer`s), idle↔active transitions, `Active()==false` cleanup to idle. - **Off-screen:** new unit tests with fake `ai`/`tasks` — collect→propose then `confirm` files exactly one task (CRDT-preserving); `dismiss` writes nothing; the gate holds. - **Web:** envelope routing test (idle / focus / off-screen shapes). ## Open question carried into the plan Build order. Recommended: (1) rename in place (mechanical, keeps tests green), (2) extract harness + `Mode` with focus as the only mode (tests green proves the extraction), (3) add off-screen mode, (4) reshape the frontend under the envelope. Each step leaves the daemon runnable.