7d69a1f320
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>
1639 lines
49 KiB
Markdown
1639 lines
49 KiB
Markdown
# Controller Refactor — Harness + Modes — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the focus-only `session.Controller` god-object with a generic `harness` that hosts swappable `mode`s; re-home focus as the first mode (behavior-preserving) and add off-screen mode as the second; complete the `antidrift`→`keel` rename clean-slate.
|
||
|
||
**Architecture:** A thin `Mode` interface plus optional capability interfaces (`EvidenceConsumer`, `Expirer`). The `harness.Harness` owns shared `Services` (the existing ports + the relocated async primitive + change-notify) and exactly one active mode, routing evidence/expiry/commands to it. Focus is a near-mechanical extraction guarded by its existing tests; off-screen is a small one-shot collect→propose→confirm mode.
|
||
|
||
**Tech Stack:** Go 1.26, gin (web), SSE broadcaster, ports-and-adapters (`ai`/`tasks`/`knowledge`/`evidence`/`enforce`).
|
||
|
||
**Source spec:** `docs/superpowers/specs/2026-06-04-controller-refactor-design.md`
|
||
|
||
**Build order (each phase leaves the daemon runnable):**
|
||
- **Phase 0** — Rename `antidrift`→`keel` in place. Pure rename; tests stay green.
|
||
- **Phase 1** — Add the `mode` contract + `harness` (additive; old controller untouched).
|
||
- **Phase 2** — Migrate focus onto the harness; delete the old `session` wiring.
|
||
- **Phase 3** — Add off-screen mode (new `ai.Proposer` role + `tasks` create effector).
|
||
- **Phase 4** — Generalize the web/status surfaces under the state envelope.
|
||
|
||
---
|
||
|
||
## Phase 0 — Rename antidrift → keel
|
||
|
||
No behavior change, no structural change. The daemon stays focus-only. Each task ends with the full suite green.
|
||
|
||
### Task 1: Rename the Go module and all import paths
|
||
|
||
**Files:**
|
||
- Modify: `go.mod` (module line)
|
||
- Modify: all 21 `.go` files importing `antidrift/internal`
|
||
|
||
- [ ] **Step 1: Rewrite the module path**
|
||
|
||
Run:
|
||
```bash
|
||
go mod edit -module keel
|
||
grep -rl 'antidrift/internal' --include='*.go' . | xargs sed -i 's#antidrift/internal#keel/internal#g'
|
||
gofmt -w .
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build and tests**
|
||
|
||
Run: `go build ./... && go test ./...`
|
||
Expected: all packages build; all tests PASS (X11 integration tests may skip when no display — that is fine).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor: rename Go module antidrift -> keel"
|
||
```
|
||
|
||
### Task 2: Rename runtime paths, status file, and env vars
|
||
|
||
**Files:**
|
||
- Modify: `internal/store/store.go:32,38` (`.antidrift`→`.keel`, comment)
|
||
- Modify: `internal/settings/settings.go:2,20,27,33,67,71,77,78` (`.antidrift`→`.keel`, `ANTIDRIFT_*`→`KEEL_*`, comments)
|
||
- Modify: `internal/statusfile/statusfile.go:2,19,25` (`.antidrift_status`→`.keel_status`, comments)
|
||
- Modify: `internal/knowledge/file.go:25,49,58` (`.antidrift`→`.keel`, comments)
|
||
- Modify: `internal/web/settings_handlers.go:84`, `internal/web/web.go:50` (comments only)
|
||
- Test: `internal/settings/settings_test.go` (env var literals)
|
||
|
||
- [ ] **Step 1: Replace the literals**
|
||
|
||
Edit each file, replacing every `.antidrift` with `.keel` and every `ANTIDRIFT_` with `KEEL_`. The three env vars become `KEEL_AI_BACKEND`, `KEEL_MARVIN_CMD`, `KEEL_KNOWLEDGE_FILE`. The status-file const becomes `const statusFileName = ".keel_status"`.
|
||
|
||
- [ ] **Step 2: Update the settings test env literals**
|
||
|
||
In `internal/settings/settings_test.go`, replace any `ANTIDRIFT_*` env names with their `KEEL_*` equivalents so `TestSeedFromEnv` (or equivalent) still exercises the live names.
|
||
|
||
- [ ] **Step 3: Verify**
|
||
|
||
Run: `go test ./internal/settings/... ./internal/store/... ./internal/statusfile/... ./internal/knowledge/...`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor: move runtime dir/status/env to keel (~/.keel, KEEL_*)"
|
||
```
|
||
|
||
### Task 3: Rename the binary cmd/antidriftd → cmd/keeld
|
||
|
||
**Files:**
|
||
- Rename: `cmd/antidriftd/` → `cmd/keeld/`
|
||
- Modify: `cmd/keeld/main.go:1,116` (package doc, listen log)
|
||
|
||
- [ ] **Step 1: Move and edit**
|
||
|
||
Run:
|
||
```bash
|
||
git mv cmd/antidriftd cmd/keeld
|
||
```
|
||
Then in `cmd/keeld/main.go` change the leading comment `Command antidriftd is the AntiDrift focus daemon` to `Command keeld is the Keel daemon`, and the log line `antidriftd listening on http://%s` to `keeld listening on http://%s`.
|
||
|
||
- [ ] **Step 2: Verify**
|
||
|
||
Run: `go build ./cmd/keeld && go vet ./...`
|
||
Expected: builds; no vet errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor: rename binary antidriftd -> keeld"
|
||
```
|
||
|
||
### Task 4: Update docs to reflect the completed rename
|
||
|
||
**Files:**
|
||
- Modify: `AGENTS.md` (the `CLAUDE.md` symlink target) — naming-status section + `go run` command
|
||
- Modify: `README.md` — `go run ./cmd/antidriftd` → `go run ./cmd/keeld`
|
||
- Modify: `docs/keel-architecture.md` §1, §8 (rename now done, not deferred)
|
||
|
||
- [ ] **Step 1: Edit the three docs**
|
||
|
||
In `AGENTS.md`, replace the "code identity is intentionally still `antidrift`" paragraph with a note that the rename is complete (module `keel`, `keeld` binary, `~/.keel/` runtime, `KEEL_*` env). Update the `go run ./cmd/antidriftd` example to `go run ./cmd/keeld`. Do the same `go run` fix in `README.md`. In `docs/keel-architecture.md` §8, remove "Controller refactor shape" from open questions and move the rename to resolved; fix the §1 note that says only the code identity is still antidrift.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "docs: mark antidrift->keel rename complete"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 1 — The mode contract + harness (additive)
|
||
|
||
New packages, fully unit-tested against a fake mode. The old `session`-based daemon keeps running unchanged; nothing wires the harness yet.
|
||
|
||
### Task 5: Define the Mode contract
|
||
|
||
**Files:**
|
||
- Create: `internal/mode/mode.go`
|
||
- Test: (covered by harness tests in Task 9)
|
||
|
||
- [ ] **Step 1: Write the contract**
|
||
|
||
```go
|
||
// Package mode defines the contract the harness uses to host a single
|
||
// collect→brain→act unit. Focus and off-screen are implementations.
|
||
package mode
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"time"
|
||
|
||
"keel/internal/evidence"
|
||
)
|
||
|
||
// Mode is one collect→brain→act configuration. The harness runs at most one.
|
||
type Mode interface {
|
||
// Kind identifies the mode ("focus", "offscreen").
|
||
Kind() string
|
||
// Command routes a surface-issued command into the mode. It returns an
|
||
// error for illegal/invalid commands; the web layer maps it to HTTP.
|
||
Command(ctx context.Context, name string, payload json.RawMessage) error
|
||
// View projects the mode's current state for surfaces (JSON-marshalable).
|
||
View() any
|
||
// Active reports whether the mode still holds the harness. When it returns
|
||
// false the harness releases the mode and goes idle.
|
||
Active() bool
|
||
}
|
||
|
||
// EvidenceConsumer is implemented by modes that read the window sensor stream.
|
||
type EvidenceConsumer interface {
|
||
OnWindow(evidence.WindowSnapshot)
|
||
}
|
||
|
||
// Expirer is implemented by modes with a server-authoritative deadline. Expire
|
||
// takes no context to match focus's existing Expire() error method, so focus
|
||
// satisfies this interface with zero changes to its tested surface.
|
||
type Expirer interface {
|
||
Deadline() time.Time
|
||
Expire() error
|
||
}
|
||
|
||
// Envelope is the surfacing wrapper: the active mode's kind plus its View().
|
||
type Envelope struct {
|
||
ActiveMode string `json:"active_mode"` // "" = idle
|
||
Mode any `json:"mode,omitempty"`
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `go build ./internal/mode/`
|
||
Expected: builds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/mode/mode.go
|
||
git commit -m "feat: add mode contract (Mode, capability interfaces, Envelope)"
|
||
```
|
||
|
||
### Task 6: Relocate the async primitive into the harness
|
||
|
||
**Files:**
|
||
- Create: `internal/harness/async.go`
|
||
- Test: `internal/harness/async_test.go`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package harness
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestAsyncRunAppliesAndNotifies(t *testing.T) {
|
||
var mu sync.Mutex
|
||
notified := make(chan struct{}, 1)
|
||
a := NewAsync(&mu, func() { notified <- struct{}{} })
|
||
|
||
var applied bool
|
||
a.Run(time.Second,
|
||
func(ctx context.Context) {},
|
||
func() bool { return false },
|
||
func() { applied = true },
|
||
)
|
||
select {
|
||
case <-notified:
|
||
case <-time.After(time.Second):
|
||
t.Fatal("notify never fired")
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !applied {
|
||
t.Fatal("apply did not run")
|
||
}
|
||
}
|
||
|
||
func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) {
|
||
var mu sync.Mutex
|
||
a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") })
|
||
done := make(chan struct{})
|
||
a.Run(time.Second,
|
||
func(ctx context.Context) {},
|
||
func() bool { close(done); return true },
|
||
func() { t.Fatal("apply ran on stale result") },
|
||
)
|
||
select {
|
||
case <-done:
|
||
case <-time.After(time.Second):
|
||
t.Fatal("stale guard never evaluated")
|
||
}
|
||
time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `go test ./internal/harness/ -run TestAsync`
|
||
Expected: FAIL (package/`NewAsync` not defined).
|
||
|
||
- [ ] **Step 3: Implement the primitive**
|
||
|
||
```go
|
||
// Package harness is the generic host: it owns shared services and exactly one
|
||
// active mode, and routes evidence, expiry, and commands to it.
|
||
package harness
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Async runs generation-guarded background work for a mode. It preserves the
|
||
// lock discipline of the original session.runFetchAsync: fetch runs with no
|
||
// lock held; stale and apply run under the mode's mutex; notify fires once,
|
||
// after the lock is released.
|
||
type Async struct {
|
||
mu *sync.Mutex
|
||
notify func()
|
||
}
|
||
|
||
// NewAsync binds the helper to a mode's mutex and the harness change-notify.
|
||
func NewAsync(mu *sync.Mutex, notify func()) Async {
|
||
return Async{mu: mu, notify: notify}
|
||
}
|
||
|
||
// Run launches a background fetch. stale returns true to DISCARD the result
|
||
// (a newer generation superseded it). apply records a non-stale result under
|
||
// the mutex and must NOT call notify — Run owns the post-unlock notify.
|
||
func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
defer cancel()
|
||
fetch(ctx)
|
||
a.mu.Lock()
|
||
if stale() {
|
||
a.mu.Unlock()
|
||
return
|
||
}
|
||
apply()
|
||
a.mu.Unlock()
|
||
if a.notify != nil {
|
||
a.notify()
|
||
}
|
||
}()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `go test ./internal/harness/ -run TestAsync`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/harness/async.go internal/harness/async_test.go
|
||
git commit -m "feat: relocate runFetchAsync as harness.Async"
|
||
```
|
||
|
||
### Task 7: Define the Services handle
|
||
|
||
**Files:**
|
||
- Create: `internal/harness/services.go`
|
||
|
||
- [ ] **Step 1: Write the Services struct**
|
||
|
||
```go
|
||
package harness
|
||
|
||
import (
|
||
"time"
|
||
|
||
"keel/internal/ai"
|
||
"keel/internal/enforce"
|
||
"keel/internal/knowledge"
|
||
"keel/internal/tasks"
|
||
)
|
||
|
||
// Services is the shared infrastructure every mode receives from the harness.
|
||
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
|
||
// modes build their own file paths under it. Notify fires the harness change
|
||
// listeners (web SSE + status bar).
|
||
type Services struct {
|
||
AI *ai.Service
|
||
Tasks tasks.Provider
|
||
Knowledge knowledge.Source
|
||
Enforce enforce.Guard
|
||
Clock func() time.Time
|
||
Dir string
|
||
Notify func()
|
||
}
|
||
```
|
||
|
||
> Note: the spec sketched `Store *store.Scope`; the plan uses `Dir string` instead — the existing `store` functions already take explicit paths, so a base dir is the smallest thing that satisfies "mode-namespaced persistence" (YAGNI).
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `go build ./internal/harness/`
|
||
Expected: builds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/harness/services.go
|
||
git commit -m "feat: add harness.Services handle"
|
||
```
|
||
|
||
### Task 8: Implement the Harness host
|
||
|
||
**Files:**
|
||
- Create: `internal/harness/harness.go`
|
||
|
||
- [ ] **Step 1: Write the host**
|
||
|
||
```go
|
||
package harness
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"sync"
|
||
"time"
|
||
|
||
"keel/internal/evidence"
|
||
"keel/internal/mode"
|
||
)
|
||
|
||
var (
|
||
ErrBusy = errors.New("harness: another mode is active")
|
||
ErrUnknownMode = errors.New("harness: unknown mode")
|
||
ErrIdle = errors.New("harness: no active mode")
|
||
)
|
||
|
||
// Factory builds a mode from the shared services, in its initial active state.
|
||
type Factory func(Services) mode.Mode
|
||
|
||
type Harness struct {
|
||
mu sync.Mutex
|
||
active mode.Mode
|
||
factories map[string]Factory
|
||
services Services
|
||
onChange []func()
|
||
}
|
||
|
||
// New wires Services.Notify to the harness so every mode's async completion
|
||
// reaches all listeners, then stores the services for the factories to receive.
|
||
func New(services Services, factories map[string]Factory) *Harness {
|
||
h := &Harness{factories: factories}
|
||
services.Notify = h.notify
|
||
h.services = services
|
||
return h
|
||
}
|
||
|
||
// AddOnChange registers a change listener (web broadcaster, status writer).
|
||
func (h *Harness) AddOnChange(f func()) {
|
||
h.mu.Lock()
|
||
h.onChange = append(h.onChange, f)
|
||
h.mu.Unlock()
|
||
}
|
||
|
||
// SetServices swaps the services future Start calls hand to factories. In v1 it
|
||
// affects the NEXT mode start only; an already-active mode keeps the services it
|
||
// was built with (settings changes take effect on the next session).
|
||
func (h *Harness) SetServices(s Services) {
|
||
s.Notify = h.notify
|
||
h.mu.Lock()
|
||
h.services = s
|
||
h.mu.Unlock()
|
||
}
|
||
|
||
func (h *Harness) notify() {
|
||
h.mu.Lock()
|
||
fs := append([]func(){}, h.onChange...)
|
||
h.mu.Unlock()
|
||
for _, f := range fs {
|
||
if f != nil {
|
||
f()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Adopt installs an already-constructed mode as active (startup restoration).
|
||
// It fails if a mode is already active.
|
||
func (h *Harness) Adopt(m mode.Mode) error {
|
||
h.mu.Lock()
|
||
if h.active != nil {
|
||
h.mu.Unlock()
|
||
return ErrBusy
|
||
}
|
||
h.active = m
|
||
h.mu.Unlock()
|
||
h.notify()
|
||
return nil
|
||
}
|
||
|
||
// Start activates a mode by kind. Idempotent if that kind is already active;
|
||
// ErrBusy if a different kind is active.
|
||
func (h *Harness) Start(kind string) error {
|
||
h.mu.Lock()
|
||
if h.active != nil {
|
||
busy := h.active.Kind() != kind
|
||
h.mu.Unlock()
|
||
if busy {
|
||
return ErrBusy
|
||
}
|
||
return nil
|
||
}
|
||
f, ok := h.factories[kind]
|
||
if !ok {
|
||
h.mu.Unlock()
|
||
return ErrUnknownMode
|
||
}
|
||
h.active = f(h.services)
|
||
h.mu.Unlock()
|
||
h.notify()
|
||
return nil
|
||
}
|
||
|
||
// Command routes to the active mode, then releases it if it went inactive.
|
||
func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error {
|
||
h.mu.Lock()
|
||
m := h.active
|
||
h.mu.Unlock()
|
||
if m == nil {
|
||
return ErrIdle
|
||
}
|
||
if err := m.Command(ctx, name, payload); err != nil {
|
||
return err
|
||
}
|
||
h.releaseIfDone()
|
||
h.notify()
|
||
return nil
|
||
}
|
||
|
||
func (h *Harness) releaseIfDone() {
|
||
h.mu.Lock()
|
||
if h.active != nil && !h.active.Active() {
|
||
h.active = nil
|
||
}
|
||
h.mu.Unlock()
|
||
}
|
||
|
||
// State returns the surfacing envelope.
|
||
func (h *Harness) State() mode.Envelope {
|
||
h.mu.Lock()
|
||
m := h.active
|
||
h.mu.Unlock()
|
||
if m == nil {
|
||
return mode.Envelope{}
|
||
}
|
||
return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()}
|
||
}
|
||
|
||
// RecordWindow forwards a window snapshot iff the active mode consumes evidence.
|
||
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) {
|
||
h.mu.Lock()
|
||
m := h.active
|
||
h.mu.Unlock()
|
||
if c, ok := m.(mode.EvidenceConsumer); ok {
|
||
c.OnWindow(w)
|
||
}
|
||
}
|
||
|
||
// Deadline returns the active mode's deadline iff it is an Expirer.
|
||
func (h *Harness) Deadline() time.Time {
|
||
h.mu.Lock()
|
||
m := h.active
|
||
h.mu.Unlock()
|
||
if e, ok := m.(mode.Expirer); ok {
|
||
return e.Deadline()
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
// Expire fires the active mode's expiry iff it is an Expirer, then releases it
|
||
// if it went inactive.
|
||
func (h *Harness) Expire() error {
|
||
h.mu.Lock()
|
||
m := h.active
|
||
h.mu.Unlock()
|
||
e, ok := m.(mode.Expirer)
|
||
if !ok {
|
||
return ErrIdle
|
||
}
|
||
if err := e.Expire(); err != nil {
|
||
return err
|
||
}
|
||
h.releaseIfDone()
|
||
h.notify()
|
||
return nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `go build ./internal/harness/`
|
||
Expected: builds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/harness/harness.go
|
||
git commit -m "feat: implement harness host (single-active mode router)"
|
||
```
|
||
|
||
### Task 9: Harness behavior tests with a fake mode
|
||
|
||
**Files:**
|
||
- Test: `internal/harness/harness_test.go`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```go
|
||
package harness
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"keel/internal/evidence"
|
||
"keel/internal/mode"
|
||
)
|
||
|
||
// fakeMode implements Mode and (optionally) the capability interfaces.
|
||
type fakeMode struct {
|
||
mu sync.Mutex
|
||
kind string
|
||
active bool
|
||
cmds []string
|
||
windows int
|
||
deadline time.Time
|
||
expired bool
|
||
evidence bool // implements EvidenceConsumer when true
|
||
expirer bool // implements Expirer when true
|
||
}
|
||
|
||
func (f *fakeMode) Kind() string { return f.kind }
|
||
func (f *fakeMode) View() any { return map[string]bool{"active": f.active} }
|
||
func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active }
|
||
func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.cmds = append(f.cmds, name)
|
||
if name == "finish" {
|
||
f.active = false
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type evidenceMode struct{ *fakeMode }
|
||
func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() }
|
||
|
||
type expirerMode struct{ *fakeMode }
|
||
func (e expirerMode) Deadline() time.Time { return e.deadline }
|
||
func (e expirerMode) Expire() error { e.mu.Lock(); e.expired = true; e.active = false; e.mu.Unlock(); return nil }
|
||
|
||
func newHarness(m mode.Mode) *Harness {
|
||
return New(Services{Clock: time.Now}, map[string]Factory{
|
||
m.Kind(): func(Services) mode.Mode { return m },
|
||
})
|
||
}
|
||
|
||
func TestStartSingleActiveInvariant(t *testing.T) {
|
||
h := New(Services{}, map[string]Factory{
|
||
"a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} },
|
||
"b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} },
|
||
})
|
||
if err := h.Start("a"); err != nil {
|
||
t.Fatalf("Start a: %v", err)
|
||
}
|
||
if err := h.Start("a"); err != nil {
|
||
t.Fatalf("re-Start a should be idempotent: %v", err)
|
||
}
|
||
if err := h.Start("b"); err != ErrBusy {
|
||
t.Fatalf("Start b while a active = %v, want ErrBusy", err)
|
||
}
|
||
if got := h.State().ActiveMode; got != "a" {
|
||
t.Fatalf("ActiveMode = %q, want a", got)
|
||
}
|
||
}
|
||
|
||
func TestCommandReleasesWhenInactive(t *testing.T) {
|
||
m := &fakeMode{kind: "a", active: true}
|
||
h := newHarness(m)
|
||
_ = h.Start("a")
|
||
if err := h.Command(context.Background(), "finish", nil); err != nil {
|
||
t.Fatalf("Command: %v", err)
|
||
}
|
||
if got := h.State().ActiveMode; got != "" {
|
||
t.Fatalf("after finish ActiveMode = %q, want idle", got)
|
||
}
|
||
}
|
||
|
||
func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) {
|
||
plain := &fakeMode{kind: "plain", active: true}
|
||
h := newHarness(plain)
|
||
_ = h.Start("plain")
|
||
h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic
|
||
if plain.windows != 0 {
|
||
t.Fatalf("plain mode received %d windows, want 0", plain.windows)
|
||
}
|
||
|
||
ev := evidenceMode{&fakeMode{kind: "ev", active: true}}
|
||
h2 := newHarness(ev)
|
||
_ = h2.Start("ev")
|
||
h2.RecordWindow(evidence.WindowSnapshot{})
|
||
if ev.windows != 1 {
|
||
t.Fatalf("evidence mode received %d windows, want 1", ev.windows)
|
||
}
|
||
}
|
||
|
||
func TestExpiryOnlyForExpirers(t *testing.T) {
|
||
plain := &fakeMode{kind: "plain", active: true}
|
||
h := newHarness(plain)
|
||
_ = h.Start("plain")
|
||
if err := h.Expire(); err != ErrIdle {
|
||
t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err)
|
||
}
|
||
if !h.Deadline().IsZero() {
|
||
t.Fatal("non-expirer Deadline should be zero")
|
||
}
|
||
|
||
ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}}
|
||
h2 := newHarness(ex)
|
||
_ = h2.Start("ex")
|
||
if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) {
|
||
t.Fatalf("Deadline = %v, want unix 100", got)
|
||
}
|
||
if err := h2.Expire(); err != nil {
|
||
t.Fatalf("Expire: %v", err)
|
||
}
|
||
if got := h2.State().ActiveMode; got != "" {
|
||
t.Fatalf("after Expire ActiveMode = %q, want idle", got)
|
||
}
|
||
}
|
||
|
||
func TestCommandWhileIdle(t *testing.T) {
|
||
h := New(Services{}, map[string]Factory{})
|
||
if err := h.Command(context.Background(), "x", nil); err != ErrIdle {
|
||
t.Fatalf("Command while idle = %v, want ErrIdle", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails, then passes**
|
||
|
||
Run: `go test ./internal/harness/ -v`
|
||
Expected: compiles and PASS (the host from Task 8 already satisfies these).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/harness/harness_test.go
|
||
git commit -m "test: harness single-active, routing, capability assertion"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — Migrate focus onto the harness
|
||
|
||
Move the focus implementation into `mode/focus`, adapt it to satisfy `mode.Mode`, and rewire `main`/`web` to drive the harness. The **existing focus tests move with the code and must pass unchanged** — they are the regression oracle. This phase ends with the old `session` package deleted.
|
||
|
||
### Task 10: Move domain and statemachine under focus
|
||
|
||
**Files:**
|
||
- Rename: `internal/domain/` → `internal/mode/focus/domain/`
|
||
- Rename: `internal/statemachine/` → `internal/mode/focus/statemachine/`
|
||
- Modify: every importer of `keel/internal/domain` and `keel/internal/statemachine`
|
||
|
||
- [ ] **Step 1: Move and rewrite imports**
|
||
|
||
Run:
|
||
```bash
|
||
mkdir -p internal/mode/focus
|
||
git mv internal/domain internal/mode/focus/domain
|
||
git mv internal/statemachine internal/mode/focus/statemachine
|
||
grep -rl 'keel/internal/domain' --include='*.go' . | xargs sed -i 's#keel/internal/domain#keel/internal/mode/focus/domain#g'
|
||
grep -rl 'keel/internal/statemachine' --include='*.go' . | xargs sed -i 's#keel/internal/statemachine#keel/internal/mode/focus/statemachine#g'
|
||
gofmt -w .
|
||
```
|
||
|
||
- [ ] **Step 2: Verify**
|
||
|
||
Run: `go build ./... && go test ./internal/mode/focus/domain/... ./internal/mode/focus/statemachine/...`
|
||
Expected: builds; PASS. (`store`, `statusfile`, `web`, `session` still import these via the new paths — that is fine for now.)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor: move domain + statemachine under mode/focus"
|
||
```
|
||
|
||
### Task 11: Move the session package to mode/focus and rename the type
|
||
|
||
**Files:**
|
||
- Rename: `internal/session/` → `internal/mode/focus/` (merge alongside `domain/`, `statemachine/`)
|
||
- Modify: package declaration `package session` → `package focus`; type `Controller` → `Mode`
|
||
|
||
- [ ] **Step 1: Move the files**
|
||
|
||
Run:
|
||
```bash
|
||
git mv internal/session/session.go internal/mode/focus/focus.go
|
||
git mv internal/session/roles.go internal/mode/focus/roles.go
|
||
git mv internal/session/drift.go internal/mode/focus/drift.go
|
||
git mv internal/session/stats.go internal/mode/focus/stats.go
|
||
git mv internal/session/views.go internal/mode/focus/views.go
|
||
git mv internal/session/session_test.go internal/mode/focus/focus_test.go
|
||
```
|
||
|
||
- [ ] **Step 2: Rename package and type**
|
||
|
||
Run:
|
||
```bash
|
||
sed -i 's/^package session$/package focus/' internal/mode/focus/*.go
|
||
sed -i 's/\*Controller/*Mode/g; s/\bController\b/Mode/g' internal/mode/focus/*.go
|
||
gofmt -w internal/mode/focus/
|
||
```
|
||
Manually confirm `New(snapshotPath string) (*Mode, error)` and the `Mode` struct doc comment read sensibly after the rename.
|
||
|
||
- [ ] **Step 3: Verify the package compiles in isolation**
|
||
|
||
Run: `go build ./internal/mode/focus/`
|
||
Expected: builds. (`store`/`statusfile`/`web`/`cmd` still reference `keel/internal/session` and will fail `go build ./...` until Tasks 12–14 — that is expected; build only the focus package here.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor: move session -> mode/focus, Controller -> focus.Mode"
|
||
```
|
||
|
||
### Task 12: Make focus.Mode satisfy the mode contract
|
||
|
||
**Files:**
|
||
- Create: `internal/mode/focus/mode.go`
|
||
- Modify: `internal/mode/focus/focus.go` (use `harness.Async`; add a terminal-state notion)
|
||
|
||
- [ ] **Step 1: Add the contract adapter**
|
||
|
||
```go
|
||
package focus
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"keel/internal/mode/focus/domain"
|
||
)
|
||
|
||
// Kind identifies this mode to the harness.
|
||
func (m *Mode) Kind() string { return "focus" }
|
||
|
||
var errBadPayload = errors.New("focus: invalid command payload")
|
||
|
||
type commitmentPayload struct {
|
||
NextAction string `json:"next_action"`
|
||
SuccessCondition string `json:"success_condition"`
|
||
TimeboxSecs int64 `json:"timebox_secs"`
|
||
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||
Enforce bool `json:"enforce"`
|
||
}
|
||
|
||
// Command maps surface command names onto the existing focus transitions.
|
||
func (m *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
|
||
switch name {
|
||
case "planning":
|
||
return m.EnterPlanning()
|
||
case "coach":
|
||
var r struct {
|
||
Intent string `json:"intent"`
|
||
}
|
||
if err := json.Unmarshal(payload, &r); err != nil {
|
||
return errBadPayload
|
||
}
|
||
return m.RequestCoach(r.Intent)
|
||
case "commitment":
|
||
var r commitmentPayload
|
||
if err := json.Unmarshal(payload, &r); err != nil {
|
||
return errBadPayload
|
||
}
|
||
level := domain.EnforcementWarn
|
||
if r.Enforce {
|
||
level = domain.EnforcementBlock
|
||
}
|
||
return m.StartManualCommitment(r.NextAction, r.SuccessCondition,
|
||
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
|
||
case "complete":
|
||
return m.Complete()
|
||
case "end":
|
||
return m.End()
|
||
case "refocus":
|
||
return m.Refocus()
|
||
case "ontask":
|
||
return m.OnTask()
|
||
default:
|
||
return fmt.Errorf("focus: unknown command %q", name)
|
||
}
|
||
}
|
||
|
||
// View returns the focus state projection (the existing State shape).
|
||
func (m *Mode) View() any { return m.State() }
|
||
|
||
// Active is true from Planning through Review. End() drives the runtime back to
|
||
// Locked, which the harness reads as "done" and releases.
|
||
func (m *Mode) Active() bool {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return m.runtimeState != domain.RuntimeLocked
|
||
}
|
||
|
||
// OnWindow forwards the sensor stream to the existing evidence recorder.
|
||
func (m *Mode) OnWindow(w evidence.WindowSnapshot) { m.RecordWindow(w) }
|
||
```
|
||
|
||
> `Deadline() time.Time` (`session.go:175`) and `Expire() error` (`session.go:277`) already exist on the type and satisfy `mode.Expirer` **as-is** — that is why `Expirer.Expire` takes no context. `RecordWindow` already exists (`session.go`, fed by `evidence.Source.Watch`). So the only new methods are `Kind`, `Command`, `View`, `Active`, `OnWindow`. Add the `keel/internal/evidence` import to `mode.go`. **Do not** touch focus's existing `onChanges`/`notify`/`SetOnChange`/`AddOnChange`/`Set*` methods — they stay and keep the moved tests valid.
|
||
|
||
- [ ] **Step 2: Swap runFetchAsync for harness.Async**
|
||
|
||
In `roles.go`, delete the `runFetchAsync` method (now `harness.Async.Run`). Add an `async harness.Async` field to the `Mode` struct and, in `New` (`focus.go`), initialize it wired to focus's **existing** `notify` fan-out:
|
||
```go
|
||
m.async = harness.NewAsync(&m.mu, m.notify)
|
||
```
|
||
Then replace each `c.runFetchAsync(` call site with `c.async.Run(`; the closure bodies are unchanged. Because `m.notify` still fans out over `c.onChanges`, every test that observes via `SetOnChange`/`AddOnChange` keeps working, and the harness later registers `svc.Notify` as one of those listeners (Task 13).
|
||
|
||
- [ ] **Step 3: Verify focus builds**
|
||
|
||
Run: `go build ./internal/mode/focus/`
|
||
Expected: builds.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add internal/mode/focus/
|
||
git commit -m "feat: focus.Mode satisfies mode.Mode + capability interfaces"
|
||
```
|
||
|
||
### Task 13: Add focus Factory/Restore (constructor + setters unchanged)
|
||
|
||
**Files:**
|
||
- Create: `internal/mode/focus/factory.go`
|
||
|
||
The existing `New(snapshotPath string)` and the `Set*` setters (`SetClock`,
|
||
`SetCoach`, `SetTasks`, `SetKnowledge`, `SetReviewer`, `SetDriftJudge`,
|
||
`SetNudge`, `SetGuard`, `SetOnChange`/`AddOnChange`) stay **exactly as they
|
||
are** — they are the injection API the moved tests already use, so the tests
|
||
need no construction changes. The factory uses that same API.
|
||
|
||
- [ ] **Step 1: Add the factory + restore helpers**
|
||
|
||
```go
|
||
package focus
|
||
|
||
import (
|
||
"path/filepath"
|
||
|
||
"keel/internal/harness"
|
||
"keel/internal/mode"
|
||
)
|
||
|
||
// build constructs a focus Mode under the mode's namespaced dir and injects the
|
||
// shared services through the existing setters (the same API tests use).
|
||
func build(svc harness.Services) (*Mode, error) {
|
||
m, err := New(filepath.Join(svc.Dir, "state.json"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if svc.Clock != nil {
|
||
m.SetClock(svc.Clock)
|
||
}
|
||
m.SetCoach(svc.AI)
|
||
m.SetReviewer(svc.AI)
|
||
m.SetDriftJudge(svc.AI)
|
||
m.SetNudge(svc.AI)
|
||
m.SetTasks(svc.Tasks)
|
||
m.SetKnowledge(svc.Knowledge)
|
||
m.SetGuard(svc.Enforce)
|
||
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
|
||
return m, nil
|
||
}
|
||
|
||
// Factory builds a fresh focus mode entering Planning (used by harness.Start).
|
||
func Factory(svc harness.Services) mode.Mode {
|
||
m, err := build(svc)
|
||
if err != nil {
|
||
m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works
|
||
m.SetOnChange(svc.Notify)
|
||
}
|
||
_ = m.EnterPlanning()
|
||
return m
|
||
}
|
||
|
||
// Restore loads a persisted focus session and reports whether one was live
|
||
// (Planning/Active/Transition/Review). Used at startup to re-adopt a session.
|
||
func Restore(svc harness.Services) (*Mode, bool, error) {
|
||
m, err := build(svc)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
return m, m.Active(), nil
|
||
}
|
||
```
|
||
|
||
> `svc.AI` is `*ai.Service`, which implements `Coach`/`Reviewer`/`DriftJudge`/`Nudger` — pass it to each role setter. Confirm the exact setter names against `roles.go`/`drift.go` when executing; the list above matches the methods read in Phase 0.
|
||
|
||
- [ ] **Step 2: Verify**
|
||
|
||
Run: `go build ./internal/mode/focus/`
|
||
Expected: builds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/mode/focus/factory.go
|
||
git commit -m "feat: focus Factory/Restore inject services via existing setters"
|
||
```
|
||
|
||
### Task 14: Rewire web to the harness
|
||
|
||
**Files:**
|
||
- Modify: `internal/web/web.go` (server holds `*harness.Harness`; generalized routes)
|
||
- Modify: `internal/web/broadcaster.go` (unchanged mechanism; verify imports)
|
||
- Test: `internal/web/web_test.go` (retarget to harness)
|
||
|
||
- [ ] **Step 1: Replace the controller field and routes**
|
||
|
||
In `web.go`, change `ctrl *session.Controller` to `h *harness.Harness`. Replace the focus-specific routes with the generalized pair plus the kept SSE/settings routes:
|
||
|
||
```go
|
||
r.GET("/events", s.handleEvents)
|
||
r.POST("/mode/:kind/start", s.handleStart)
|
||
r.POST("/mode/command/:name", s.handleCommand)
|
||
r.GET("/settings", s.handleGetSettings)
|
||
r.POST("/settings", s.handlePostSettings)
|
||
r.GET("/fs/browse", s.handleBrowse)
|
||
```
|
||
|
||
```go
|
||
func (s *Server) handleStart(c *gin.Context) {
|
||
kind := c.Param("kind")
|
||
err := s.h.Start(kind)
|
||
if err == nil && kind == "focus" {
|
||
s.armExpiry()
|
||
}
|
||
s.respond(c, err)
|
||
}
|
||
|
||
func (s *Server) handleCommand(c *gin.Context) {
|
||
name := c.Param("name")
|
||
body, _ := io.ReadAll(c.Request.Body)
|
||
err := s.h.Command(c.Request.Context(), name, body)
|
||
switch name {
|
||
case "commitment":
|
||
if err == nil {
|
||
s.armExpiry()
|
||
}
|
||
case "complete", "end":
|
||
s.cancelExpiry()
|
||
}
|
||
s.respond(c, err)
|
||
}
|
||
```
|
||
|
||
`stateJSON` marshals `s.h.State()`. `armExpiry`/`cancelExpiry`/`Init` use `s.h.Deadline()` and `s.h.Expire(context.Background())` instead of the controller methods. `respond` maps `harness.ErrBusy`/`ErrUnknownMode`/`ErrIdle` and `statemachine.IllegalTransitionError` to 409/400 as today.
|
||
|
||
- [ ] **Step 2: Update the web test to drive the harness**
|
||
|
||
In `web_test.go`, construct a harness with the focus factory (or a fake mode) instead of a raw controller, and update endpoint URLs to `/mode/focus/start` and `/mode/command/commitment` etc. Keep assertions on the resulting envelope JSON (`active_mode`, `mode`).
|
||
|
||
- [ ] **Step 3: Verify**
|
||
|
||
Run: `go build ./internal/web/ && go test ./internal/web/`
|
||
Expected: builds; PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add internal/web/
|
||
git commit -m "refactor: web server drives the harness via generalized routes"
|
||
```
|
||
|
||
### Task 15: Rewire statusfile and main; delete the old session package
|
||
|
||
**Files:**
|
||
- Modify: `internal/statusfile/statusfile.go` (read `mode.Envelope` instead of `session.State`)
|
||
- Modify: `cmd/keeld/main.go` (build Services, harness, register focus, restore)
|
||
- Delete: `internal/session/` (now empty after Task 11 moves)
|
||
|
||
- [ ] **Step 1: Retarget statusfile**
|
||
|
||
Change the writer to accept `func() mode.Envelope` and render focus's line from `env.Mode` when `env.ActiveMode == "focus"`, an idle line when `env.ActiveMode == ""`. Update `statusfile_test.go` accordingly.
|
||
|
||
- [ ] **Step 2: Rebuild main around the harness**
|
||
|
||
```go
|
||
func main() {
|
||
home, _ := os.UserHomeDir()
|
||
root := filepath.Join(home, ".keel")
|
||
|
||
cfg := loadSettings() // existing settings load/seed, now under ~/.keel
|
||
backend, err := ai.NewBackend(cfg.AIBackend)
|
||
if err != nil { log.Fatalf("ai backend: %v", err) }
|
||
svc := ai.NewService(backend)
|
||
|
||
base := harness.Services{
|
||
AI: svc,
|
||
Tasks: tasks.NewMarvin(cfg.MarvinCmd),
|
||
Knowledge: knowledge.NewFileSource(cfg.KnowledgePath),
|
||
Enforce: enforce.NewGuard(),
|
||
Clock: time.Now,
|
||
}
|
||
h := harness.New(base, map[string]harness.Factory{
|
||
"focus": focus.Factory,
|
||
"offscreen": offscreen.Factory, // added in Phase 3
|
||
})
|
||
|
||
// Restore a live focus session, if any.
|
||
focusSvc := base
|
||
focusSvc.Dir = filepath.Join(root, "modes", "focus")
|
||
if m, live, err := focus.Restore(focusSvc); err == nil && live {
|
||
_ = h.Adopt(m)
|
||
}
|
||
|
||
srv := web.NewServer(h)
|
||
srv.Init()
|
||
// ... statusfile writer (h.State), evidence source (h.RecordWindow), settings apply, listen ...
|
||
}
|
||
```
|
||
|
||
> Per-mode `Dir` is injected by the factory wrapper: register `focus.Factory` wrapped so it sets `svc.Dir = root/modes/focus` before constructing. Do the same for offscreen (`root/modes/offscreen`):
|
||
> ```go
|
||
> withDir := func(dir string, f harness.Factory) harness.Factory {
|
||
> return func(svc harness.Services) mode.Mode { svc.Dir = dir; return f(svc) }
|
||
> }
|
||
> // "focus": withDir(filepath.Join(root,"modes","focus"), focus.Factory), etc.
|
||
> ```
|
||
> The settings `applyFn` rebuilds the AI/tasks/knowledge ports into a fresh `harness.Services` and calls `h.SetServices(...)` (added in Task 8); per its documented v1 semantics, that affects the next mode start, not the already-active mode.
|
||
|
||
- [ ] **Step 3: Delete the empty session dir and verify the whole build**
|
||
|
||
Run:
|
||
```bash
|
||
rmdir internal/session 2>/dev/null || git rm -r internal/session
|
||
go build ./... && go test ./...
|
||
```
|
||
Expected: whole module builds; **all focus tests pass unchanged** (the regression oracle); harness/web/statusfile tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor: wire keeld through the harness; remove session package"
|
||
```
|
||
|
||
### Task 16: Manual smoke test of focus parity
|
||
|
||
- [ ] **Step 1: Run the daemon and exercise focus end to end**
|
||
|
||
Run: `go run ./cmd/keeld`
|
||
Then in the browser at `http://localhost:7777`: start focus → plan → coach → commit (with a timebox) → observe evidence/drift → complete → review → end. Confirm behavior matches pre-refactor focus.
|
||
|
||
- [ ] **Step 2: Confirm the runtime moved**
|
||
|
||
Run: `ls ~/.keel/modes/focus/`
|
||
Expected: `state.json`, `audit.jsonl`, `sessions/` after a completed session.
|
||
|
||
---
|
||
|
||
## Phase 3 — Off-screen mode
|
||
|
||
A one-shot collect→propose→confirm mode. New `ai.Proposer` role, new `tasks` create effector, new `mode/offscreen` package.
|
||
|
||
### Task 17: Add the tasks.Create effector
|
||
|
||
**Files:**
|
||
- Modify: `internal/tasks/tasks.go` (extend `Provider`; add `NewTask`)
|
||
- Modify: `internal/tasks/marvin.go` (implement `Create`)
|
||
- Test: `internal/tasks/marvin_test.go`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) {
|
||
var gotArgs []string
|
||
m := &Marvin{run: func(ctx context.Context, args ...string) ([]byte, error) {
|
||
gotArgs = args
|
||
return []byte(`{"id":"task-1"}`), nil
|
||
}}
|
||
err := m.Create(context.Background(), tasks.NewTask("buy groceries"))
|
||
if err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
// add subcommand present and title passed through
|
||
joined := strings.Join(gotArgs, " ")
|
||
if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") {
|
||
t.Fatalf("args = %v, want marvin add with title", gotArgs)
|
||
}
|
||
}
|
||
```
|
||
|
||
> Match the test to `Marvin`'s actual runner field/shape (see `marvin.go:41-53`); the example assumes a `run` func field — adapt to the real one.
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
Run: `go test ./internal/tasks/ -run TestCreate`
|
||
Expected: FAIL (`Create`/`NewTask` undefined).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In `tasks.go`:
|
||
```go
|
||
type Provider interface {
|
||
Today(ctx context.Context) ([]Task, error)
|
||
Create(ctx context.Context, t Task) error
|
||
}
|
||
|
||
// NewTask builds a minimal task for creation (title only in v1).
|
||
func NewTask(title string) Task { return Task{Title: title} }
|
||
```
|
||
In `marvin.go`, implement `Create` by invoking the Marvin CLI's add path, preserving the `fieldUpdates` CRDT semantics the read path relies on (mirror how `Today` shells out). Return the CLI error on failure.
|
||
|
||
- [ ] **Step 4: Verify**
|
||
|
||
Run: `go test ./internal/tasks/`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/tasks/
|
||
git commit -m "feat: tasks.Provider.Create effector (Marvin add, CRDT-preserving)"
|
||
```
|
||
|
||
### Task 18: Add the ai.Proposer role
|
||
|
||
**Files:**
|
||
- Create: `internal/ai/proposer.go`
|
||
- Test: `internal/ai/proposer_test.go`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package ai
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
func TestProposeParsesProposal(t *testing.T) {
|
||
svc := NewService(fakeBackend(`{"next_action":"call mum","rationale":"people goal"}`))
|
||
p, err := svc.Propose(context.Background(), "off-screen brief")
|
||
if err != nil {
|
||
t.Fatalf("Propose: %v", err)
|
||
}
|
||
if p.NextAction != "call mum" || p.Rationale != "people goal" {
|
||
t.Fatalf("proposal = %+v", p)
|
||
}
|
||
}
|
||
```
|
||
|
||
> `fakeBackend` already exists in the ai test files (used by coach/verdict tests) — reuse it; adapt the constructor name if it differs.
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
Run: `go test ./internal/ai/ -run TestPropose`
|
||
Expected: FAIL (`Propose`/`Proposal` undefined).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
```go
|
||
package ai
|
||
|
||
import "context"
|
||
|
||
// OffscreenProposal is the off-screen brain output.
|
||
type OffscreenProposal struct {
|
||
NextAction string `json:"next_action"`
|
||
Rationale string `json:"rationale"`
|
||
}
|
||
|
||
// Proposer turns an off-screen brief into a single suggested action.
|
||
type Proposer interface {
|
||
Propose(ctx context.Context, brief string) (OffscreenProposal, error)
|
||
}
|
||
|
||
func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) {
|
||
// Mirror Service.Coach: build a prompt from brief, call s.backend, parse JSON.
|
||
// Reuse the existing JSON-extraction + ErrEmptyResponse/ErrNoJSON handling.
|
||
}
|
||
```
|
||
|
||
> Use the existing `Proposal` errors and JSON helpers from `proposal.go`/`coach.go`. If the name `Proposal` already exists for focus's coach output, name the off-screen type `OffscreenProposal` (as above) to avoid collision; update the test accordingly.
|
||
|
||
- [ ] **Step 4: Verify**
|
||
|
||
Run: `go test ./internal/ai/`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/ai/
|
||
git commit -m "feat: ai.Proposer role for off-screen briefs"
|
||
```
|
||
|
||
### Task 19: Implement off-screen mode
|
||
|
||
**Files:**
|
||
- Create: `internal/mode/offscreen/offscreen.go`
|
||
- Create: `internal/mode/offscreen/collect.go` (brief assembly)
|
||
- Test: `internal/mode/offscreen/offscreen_test.go`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package offscreen
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"sync"
|
||
"testing"
|
||
|
||
"keel/internal/ai"
|
||
"keel/internal/harness"
|
||
"keel/internal/tasks"
|
||
)
|
||
|
||
type fakeTasks struct {
|
||
created []string
|
||
}
|
||
func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil }
|
||
func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error {
|
||
f.created = append(f.created, t.Title)
|
||
return nil
|
||
}
|
||
|
||
func newTestMode(t *testing.T, ft *fakeTasks) *Mode {
|
||
svc := harness.Services{
|
||
AI: ai.NewService(/* fake backend returning a proposal */),
|
||
Tasks: ft,
|
||
Clock: func() time.Time { return time.Unix(0, 0) },
|
||
Notify: func() {},
|
||
}
|
||
return New(svc)
|
||
}
|
||
|
||
func TestConfirmFilesExactlyOneTask(t *testing.T) {
|
||
ft := &fakeTasks{}
|
||
m := newTestMode(t, ft)
|
||
// drive: start -> wait proposed -> confirm
|
||
_ = m.Command(context.Background(), "start", nil)
|
||
waitProposed(t, m)
|
||
if err := m.Command(context.Background(), "confirm", nil); err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if len(ft.created) != 1 {
|
||
t.Fatalf("created %d tasks, want 1", len(ft.created))
|
||
}
|
||
if m.Active() {
|
||
t.Fatal("mode should be done after confirm")
|
||
}
|
||
}
|
||
|
||
func TestDismissWritesNothing(t *testing.T) {
|
||
ft := &fakeTasks{}
|
||
m := newTestMode(t, ft)
|
||
_ = m.Command(context.Background(), "start", nil)
|
||
waitProposed(t, m)
|
||
_ = m.Command(context.Background(), "dismiss", nil)
|
||
if len(ft.created) != 0 {
|
||
t.Fatalf("dismiss created %d tasks, want 0", len(ft.created))
|
||
}
|
||
if m.Active() {
|
||
t.Fatal("mode should be done after dismiss")
|
||
}
|
||
}
|
||
```
|
||
|
||
> `waitProposed` polls `m.View()` until status is `proposed` (the async brain call). Provide a fake backend that returns a fixed proposal JSON (reuse the ai test helper pattern).
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
Run: `go test ./internal/mode/offscreen/`
|
||
Expected: FAIL (package undefined).
|
||
|
||
- [ ] **Step 3: Implement the mode**
|
||
|
||
```go
|
||
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
|
||
// off-screen action from the life-domain frame and files it on confirm.
|
||
package offscreen
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"sync"
|
||
"time"
|
||
|
||
"keel/internal/ai"
|
||
"keel/internal/harness"
|
||
"keel/internal/mode"
|
||
"keel/internal/tasks"
|
||
)
|
||
|
||
const proposeTimeout = 60 * time.Second
|
||
|
||
const (
|
||
statusPending = "pending"
|
||
statusProposed = "proposed"
|
||
statusError = "error"
|
||
statusDone = "done"
|
||
)
|
||
|
||
type Mode struct {
|
||
mu sync.Mutex
|
||
async harness.Async
|
||
ai *ai.Service
|
||
tasks tasks.Provider
|
||
knowle knowledge.Source
|
||
dir string
|
||
clock func() time.Time
|
||
|
||
status string
|
||
gen int
|
||
proposal *ai.OffscreenProposal
|
||
errMsg string
|
||
done bool
|
||
}
|
||
|
||
func New(svc harness.Services) *Mode {
|
||
m := &Mode{
|
||
ai: svc.AI, tasks: svc.Tasks, knowle: svc.Knowledge,
|
||
dir: svc.Dir, clock: svc.Clock, status: statusPending,
|
||
}
|
||
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
||
return m
|
||
}
|
||
|
||
func (m *Mode) Kind() string { return "offscreen" }
|
||
|
||
func (m *Mode) Active() bool {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return !m.done
|
||
}
|
||
|
||
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
|
||
switch name {
|
||
case "start":
|
||
return m.start()
|
||
case "confirm":
|
||
return m.confirm(ctx)
|
||
case "dismiss":
|
||
m.mu.Lock(); m.done = true; m.mu.Unlock()
|
||
return nil
|
||
default:
|
||
return errors.New("offscreen: unknown command " + name)
|
||
}
|
||
}
|
||
|
||
func (m *Mode) start() error {
|
||
m.mu.Lock()
|
||
m.gen++
|
||
gen := m.gen
|
||
m.status = statusPending
|
||
m.mu.Unlock()
|
||
brief := m.assembleBrief() // collect.go: tasks.Today + goals + life-bugs
|
||
var p ai.OffscreenProposal
|
||
var err error
|
||
m.async.Run(proposeTimeout,
|
||
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
|
||
func() bool { return gen != m.gen },
|
||
func() {
|
||
if err != nil {
|
||
m.status = statusError
|
||
m.errMsg = err.Error()
|
||
return
|
||
}
|
||
m.status = statusProposed
|
||
m.proposal = &p
|
||
})
|
||
return nil
|
||
}
|
||
|
||
func (m *Mode) confirm(ctx context.Context) error {
|
||
m.mu.Lock()
|
||
p := m.proposal
|
||
m.mu.Unlock()
|
||
if p == nil {
|
||
return errors.New("offscreen: nothing to confirm")
|
||
}
|
||
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
|
||
return err
|
||
}
|
||
m.mu.Lock()
|
||
m.status = statusDone
|
||
m.done = true
|
||
m.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// View projects the proposal card.
|
||
func (m *Mode) View() any {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
v := map[string]any{"status": m.status}
|
||
if m.proposal != nil {
|
||
v["next_action"] = m.proposal.NextAction
|
||
v["rationale"] = m.proposal.Rationale
|
||
}
|
||
if m.errMsg != "" {
|
||
v["error"] = m.errMsg
|
||
}
|
||
return v
|
||
}
|
||
|
||
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
|
||
func Factory(svc harness.Services) mode.Mode {
|
||
m := New(svc)
|
||
_ = m.start()
|
||
return m
|
||
}
|
||
```
|
||
|
||
In `collect.go`, implement `assembleBrief()`: call `m.tasks.Today(ctx)` (best-effort), load `~/owc/goals-2026.md` via `m.knowle.Load`, glob the curated life-bug files (`filepath.Glob` over `~/owc/resources/bug-{physical-reset,workout-effort,nutrition-effort,missing-social-life,make-environment-beautiful,marriage-strategy}.md`), and concatenate into a budgeted string. Add the `knowledge` import to `offscreen.go`.
|
||
|
||
- [ ] **Step 4: Verify**
|
||
|
||
Run: `go test ./internal/mode/offscreen/`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/mode/offscreen/
|
||
git commit -m "feat: off-screen mode (collect->propose->confirm)"
|
||
```
|
||
|
||
### Task 20: Register off-screen in main and smoke test
|
||
|
||
**Files:**
|
||
- Modify: `cmd/keeld/main.go` (factory already referenced in Task 15; confirm the import + per-mode Dir wrapper)
|
||
|
||
- [ ] **Step 1: Confirm registration and Dir wiring**
|
||
|
||
Ensure `"offscreen": offscreen.Factory` is registered with its `Dir = root/modes/offscreen` wrapper, and the `offscreen` package is imported.
|
||
|
||
- [ ] **Step 2: Verify and smoke test**
|
||
|
||
Run: `go build ./... && go test ./...`
|
||
Expected: green. Then `go run ./cmd/keeld`, and via curl/browser: `POST /mode/offscreen/start`, observe a `proposed` envelope, `POST /mode/command/confirm`, confirm a Marvin task is filed and the harness returns to idle.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: register off-screen mode in keeld"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — Surfaces under the envelope
|
||
|
||
The Go side already emits `mode.Envelope`. This phase reshapes the frontend to route on `active_mode` and adds the launcher + off-screen view.
|
||
|
||
### Task 21: Frontend mode router + launcher
|
||
|
||
**Files:**
|
||
- Modify: `internal/web/static/app.js`
|
||
- Modify: `internal/web/static/index.html`
|
||
- Modify: `internal/web/static/app.css`
|
||
|
||
- [ ] **Step 1: Route on active_mode**
|
||
|
||
In `app.js`, the SSE handler now receives `{active_mode, mode}`. Branch:
|
||
- `active_mode === ""` → render the launcher (two buttons: Start focus → `POST /mode/focus/start`; Start off-screen → `POST /mode/offscreen/start`).
|
||
- `active_mode === "focus"` → render the existing focus screens, fed by `mode` (the former top-level `State`). Re-point every existing `fetch('/planning'|'/coach'|'/commitment'|'/complete'|'/end'|'/refocus'|'/ontask')` call to `POST /mode/command/<name>` (and the planning entry to `POST /mode/focus/start`).
|
||
- `active_mode === "offscreen"` → render the off-screen card (Task 22).
|
||
|
||
- [ ] **Step 2: Manual verify focus parity through the new router**
|
||
|
||
Run: `go run ./cmd/keeld`, exercise the full focus flow from the launcher. Confirm identical behavior.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/web/static/
|
||
git commit -m "feat: frontend routes on active_mode; adds mode launcher"
|
||
```
|
||
|
||
### Task 22: Off-screen proposal card
|
||
|
||
**Files:**
|
||
- Modify: `internal/web/static/app.js`, `index.html`, `app.css`
|
||
|
||
- [ ] **Step 1: Render the card**
|
||
|
||
When `active_mode === "offscreen"`, render from `mode`: `status === "pending"` → a spinner; `"proposed"` → the `next_action` + `rationale` with **Do it** (`POST /mode/command/confirm`) and **Dismiss** (`POST /mode/command/dismiss`); `"error"` → the error with a retry (`POST /mode/offscreen/start`). Phone-first layout (single column, large tap targets).
|
||
|
||
- [ ] **Step 2: Manual verify on a phone-width viewport**
|
||
|
||
Run the daemon; in devtools responsive mode, start off-screen, confirm the card renders and **Do it** files a task and returns to the launcher.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/web/static/
|
||
git commit -m "feat: off-screen proposal card (phone-first)"
|
||
```
|
||
|
||
### Task 23: Status bar under the envelope
|
||
|
||
**Files:**
|
||
- Modify: `internal/statusfile/statusfile.go` (already retargeted in Task 15 — finalize the off-screen line)
|
||
- Test: `internal/statusfile/statusfile_test.go`
|
||
|
||
- [ ] **Step 1: Render one line per active mode**
|
||
|
||
`active_mode === "focus"` → today's drift/status line (unchanged content). `"offscreen"` → e.g. `off-screen: <next_action>` when proposed, else `off-screen: thinking…`. `""` → `idle`. Add/adjust a test per branch.
|
||
|
||
- [ ] **Step 2: Verify**
|
||
|
||
Run: `go test ./internal/statusfile/`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/statusfile/
|
||
git commit -m "feat: status bar renders per active mode"
|
||
```
|
||
|
||
### Task 24: Final docs + external-config note
|
||
|
||
**Files:**
|
||
- Modify: `docs/keel-architecture.md` §5, §10 (off-screen mode with real collectors; drop the phantom "house mode")
|
||
- Modify: `README.md` (mention the two modes + launcher)
|
||
|
||
- [ ] **Step 1: Fix the architecture doc and README**
|
||
|
||
Rewrite §5 ("One loop, concrete") and §10 ("Smallest real slice") around off-screen mode using the real collectors (`goals-2026.md` + life-domain `bug-*.md` + Marvin), removing the `house-integrity` goal and the solved house bug. Note off-screen memory (AW buckets) as the next increment.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "docs: describe off-screen mode; retire the house-mode phantom"
|
||
```
|
||
|
||
- [ ] **Step 3: External config (manual, outside the repo)**
|
||
|
||
Felix updates by hand and confirms:
|
||
- WM status-bar config: read `~/.keel_status` (was `~/.antidrift_status`).
|
||
- Any systemd unit / shell alias / launcher: invoke `keeld` (was `antidriftd`).
|
||
|
||
---
|
||
|
||
## Self-review notes
|
||
|
||
- **Spec coverage:** Mode contract (T5), harness + Services + Async (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`, internal `package session` → `package focus`) keep using `New(path)` + the `Set*` setters, both preserved unchanged (T11–T13); 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).
|