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

49 KiB
Raw Permalink Blame History

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 modes; re-home focus as the first mode (behavior-preserving) and add off-screen mode as the second; complete the antidriftkeel 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 antidriftkeel 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:

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
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
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:

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
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.mdgo run ./cmd/antidriftdgo 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
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

// 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
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

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
// 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
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

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
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

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
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

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
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:

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
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 sessionpackage focus; type ControllerMode

  • Step 1: Move the files

Run:

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:

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
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

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:

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
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
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
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:

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

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:

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
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

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:

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
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

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
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
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

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
// 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
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
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
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
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
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
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 sessionpackage 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).