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>
49 KiB
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 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→keelin place. Pure rename; tests stay green. - Phase 1 — Add the
modecontract +harness(additive; old controller untouched). - Phase 2 — Migrate focus onto the harness; delete the old
sessionwiring. - Phase 3 — Add off-screen mode (new
ai.Proposerrole +taskscreate 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
.gofiles importingantidrift/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(theCLAUDE.mdsymlink target) — naming-status section +go runcommand -
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
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 usesDir stringinstead — the existingstorefunctions 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/domainandkeel/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 alongsidedomain/,statemachine/) -
Modify: package declaration
package session→package focus; typeController→Mode -
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 12–14 — 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(useharness.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) andExpire() error(session.go:277) already exist on the type and satisfymode.Expireras-is — that is whyExpirer.Expiretakes no context.RecordWindowalready exists (session.go, fed byevidence.Source.Watch). So the only new methods areKind,Command,View,Active,OnWindow. Add thekeel/internal/evidenceimport tomode.go. Do not touch focus's existingonChanges/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.AIis*ai.Service, which implementsCoach/Reviewer/DriftJudge/Nudger— pass it to each role setter. Confirm the exact setter names againstroles.go/drift.gowhen 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(readmode.Envelopeinstead ofsession.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
Diris injected by the factory wrapper: registerfocus.Factorywrapped so it setssvc.Dir = root/modes/focusbefore 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
applyFnrebuilds the AI/tasks/knowledge ports into a freshharness.Servicesand callsh.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(extendProvider; addNewTask) -
Modify:
internal/tasks/marvin.go(implementCreate) -
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 (seemarvin.go:41-53); the example assumes arunfunc 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)
}
}
fakeBackendalready 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
Proposalerrors and JSON helpers fromproposal.go/coach.go. If the nameProposalalready exists for focus's coach output, name the off-screen typeOffscreenProposal(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")
}
}
waitProposedpollsm.View()until status isproposed(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 bymode(the former top-levelState). Re-point every existingfetch('/planning'|'/coach'|'/commitment'|'/complete'|'/end'|'/refocus'|'/ontask')call toPOST /mode/command/<name>(and the planning entry toPOST /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(wasantidriftd).
Self-review notes
- Spec coverage: Mode contract (T5), harness + Services + Async (T6–T9), focus extraction + Locked→idle + Expirer/EvidenceConsumer (T10–T16), off-screen collect→propose→gated-confirm (T17–T20), envelope surfacing + launcher + status bar (T21–T23), rename in-repo + external note (T1–T4, 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, internalpackage session→package focus) keep usingNew(path)+ theSet*setters, both preserved unchanged (T11–T13); they must pass after rewiring (T15) — the proof the extraction preserved behavior. TheExpirerinterface deliberately takes no context so focus's existingExpire() errorsatisfies 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).