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>
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 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:
- Generic runtime orchestration — holding the ports, the async
brain-call primitive (
runFetchAsync), persistence, change-notify, the evidence ingestion callback, the clock. - The focus state machine —
locked→planning→active→transition→review, commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence stats, reflection, session sealing. - The focus domain payload —
domain.Commitment(next_action / success_condition / timebox), sitting in a package nameddomainas 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/harnesshost that owns shared services and exactly one active mode, and routes I/O to it. - A small
Modecontract 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/focuswith 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→keelrename 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
Modeinterface + 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. Lockedcollapses into harness idle. "No active mode" is the resting state; focus isPlanning→Active→Review→done. One fewer state.- Off-screen reuses the existing single-file
knowledge.Sourcefor the~/owc/goals-2026.mdframe, plus a small in-package glob read (filepath.Glob+ read) for the curated life-bug set.knowledge.Source.Loadis 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 statuspending.- Collect (real frame elements):
tasks.Provider.Today()— life / non-work items.~/owc/goals-2026.md(Body/Spirit, People, Via Negativa) via the existing single-fileknowledge.Source.~/owclife-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.Proposerrole —Propose(ctx, brief) → Proposal{NextAction, Rationale}— reusingai.Backend. A sibling toCoach, 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 newtasks.Provider.Create(ctx, Task)files the proposed task in Marvin, preserving thefieldUpdatesCRDT 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}/start→harness.StartPOST /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 firesharness.Expire()via theExpirerassertion (today'sweb.armExpirylogic, 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.modmoduleantidrift→keel; all import paths.cmd/antidriftd→cmd/keeld; binarykeeld; log strings.store/settings/statusfiledefault paths →~/.keel*.settings.SeedFromEnvANTIDRIFT_*→KEEL_*.CLAUDE.mdnaming-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
EvidenceConsumers; expiry only toExpirers), idle↔active transitions,Active()==falsecleanup to idle. - Off-screen: new unit tests with fake
ai/tasks— collect→propose thenconfirmfiles exactly one task (CRDT-preserving);dismisswrites 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.