Files
antidrift/docs/superpowers/plans/2026-06-04-controller-refactor.md
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

1639 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<kind>);
// 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 1214 — 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/<name>` (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: <next_action>` 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 (T6T9), focus extraction + Locked→idle + Expirer/EvidenceConsumer (T10T16), off-screen collect→propose→gated-confirm (T17T20), envelope surfacing + launcher + status bar (T21T23), rename in-repo + external note (T1T4, 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 (T11T13); 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).