Files
antidrift/docs/superpowers/specs/2026-06-04-controller-refactor-design.md
T
felixm 7d69a1f320 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>
2026-06-05 18:00:49 -04:00

14 KiB

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 antidriftkeel 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 machinelocked→planning→active→transition→review, commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence stats, reflection, session sealing.
  3. The focus domain payloaddomain.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 antidriftkeel 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

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

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:

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/<kind>/)
    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:

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

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}/startharness.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 antidriftkeel; all import paths.
  • cmd/antidriftdcmd/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 antidriftdkeeld.

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 EvidenceConsumers; expiry only to Expirers), 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.