Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// goalsPath is the standing-goals note loaded for the off-screen brief.
|
||||
const goalsPath = "~/owc/goals-2026.md"
|
||||
|
||||
// maxBriefBytes caps the assembled brief so the propose prompt stays bounded.
|
||||
const maxBriefBytes = 8 * 1024
|
||||
|
||||
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
|
||||
const bugGlob = "bug-*.md"
|
||||
|
||||
// assembleBrief builds the off-screen prompt context from today's tasks, the
|
||||
// standing goals note, and the life-domain notes. It is best-effort: it never
|
||||
// panics and never returns an error, and it tolerates nil ports and missing
|
||||
// files (degrading to a short, mostly-empty brief).
|
||||
func (m *Mode) assembleBrief() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("## Today's tasks\n")
|
||||
b.WriteString(m.briefTasks())
|
||||
b.WriteString("\n")
|
||||
|
||||
if goals := m.briefGoals(); goals != "" {
|
||||
b.WriteString("## Goals\n")
|
||||
b.WriteString(goals)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if domains := m.briefLifeDomains(); domains != "" {
|
||||
b.WriteString("## Life domains\n")
|
||||
b.WriteString(domains)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return truncateBrief(b.String(), maxBriefBytes)
|
||||
}
|
||||
|
||||
// briefTasks lists today's task titles, or "(none)" when the port is absent,
|
||||
// errors, or returns nothing.
|
||||
func (m *Mode) briefTasks() string {
|
||||
if m.tasks == nil {
|
||||
return "(none)\n"
|
||||
}
|
||||
list, err := m.tasks.Today(context.Background())
|
||||
if err != nil || len(list) == 0 {
|
||||
return "(none)\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, t := range list {
|
||||
title := strings.TrimSpace(t.Title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("- ")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "(none)\n"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// briefGoals returns the goals note text, or "" when the port is absent or the
|
||||
// file is missing/empty.
|
||||
func (m *Mode) briefGoals() string {
|
||||
if m.know == nil {
|
||||
return ""
|
||||
}
|
||||
p, err := m.know.Load(context.Background(), goalsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(p.Text)
|
||||
}
|
||||
|
||||
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
|
||||
// port (so reads honor its truncation) and concatenates their text under one
|
||||
// section. Returns "" when the port is absent or no notes are readable. File
|
||||
// access stays behind the port; when the port is nil it is skipped entirely.
|
||||
func (m *Mode) briefLifeDomains() string {
|
||||
if m.know == nil {
|
||||
return ""
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, path := range matches {
|
||||
p, err := m.know.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(p.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("### ")
|
||||
b.WriteString(filepath.Base(path))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(text)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// truncateBrief clips s to at most max bytes with a marker, keeping the prompt
|
||||
// bounded. It backs up to a rune boundary so it never splits a multibyte rune.
|
||||
func truncateBrief(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "\n…(truncated)"
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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. It is
|
||||
// one-shot — after confirm or dismiss it goes inactive and the harness releases
|
||||
// it.
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/mode"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
const proposeTimeout = 60 * time.Second
|
||||
|
||||
const (
|
||||
statusPending = "pending"
|
||||
statusProposed = "proposed"
|
||||
statusError = "error"
|
||||
statusDone = "done"
|
||||
)
|
||||
|
||||
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
||||
type Mode struct {
|
||||
mu sync.Mutex
|
||||
async harness.Async
|
||||
ai *ai.Service
|
||||
tasks tasks.Provider
|
||||
know knowledge.Source // may be nil; assembleBrief must guard
|
||||
|
||||
status string
|
||||
gen int
|
||||
proposal *ai.OffscreenProposal
|
||||
errMsg string
|
||||
done bool
|
||||
}
|
||||
|
||||
var _ mode.Mode = (*Mode)(nil)
|
||||
|
||||
// New builds an off-screen mode from the shared services. It does not start the
|
||||
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
|
||||
func New(svc harness.Services) *Mode {
|
||||
m := &Mode{
|
||||
ai: svc.AI,
|
||||
tasks: svc.Tasks,
|
||||
know: svc.Knowledge,
|
||||
status: statusPending,
|
||||
}
|
||||
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mode) Kind() string { return "offscreen" }
|
||||
|
||||
// Active reports whether the mode still holds the harness. It goes false after
|
||||
// confirm or dismiss.
|
||||
func (m *Mode) Active() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return !m.done
|
||||
}
|
||||
|
||||
// Command routes a surface command. start (re)runs the brief; confirm files the
|
||||
// proposed action and finishes; dismiss finishes without writing.
|
||||
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 fmt.Errorf("offscreen: unknown command %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// start assembles the brief and kicks off the brain asynchronously. A new call
|
||||
// bumps the generation so a stale in-flight result is discarded.
|
||||
func (m *Mode) start() error {
|
||||
m.mu.Lock()
|
||||
m.gen++
|
||||
gen := m.gen
|
||||
m.status = statusPending
|
||||
m.proposal = nil
|
||||
m.errMsg = ""
|
||||
m.mu.Unlock()
|
||||
|
||||
brief := m.assembleBrief()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// confirm files the proposed action as a task and ends the mode. It errors if
|
||||
// there is nothing to confirm yet.
|
||||
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 mode's state for surfaces. Always JSON-marshalable.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
// stubBackend is a local fake ai.Backend; ai's package-internal fake is not
|
||||
// reachable from this package, so we define our own here.
|
||||
type stubBackend struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (b stubBackend) Name() string { return "stub" }
|
||||
func (b stubBackend) Run(context.Context, string) (string, error) {
|
||||
return b.out, b.err
|
||||
}
|
||||
|
||||
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, backend ai.Backend) *Mode {
|
||||
t.Helper()
|
||||
svc := harness.Services{
|
||||
AI: ai.NewService(backend),
|
||||
Tasks: ft,
|
||||
Clock: func() time.Time { return time.Unix(0, 0) },
|
||||
Notify: func() {},
|
||||
}
|
||||
return New(svc)
|
||||
}
|
||||
|
||||
// waitStatus polls the mode's View until status reaches want, or fails after 2s.
|
||||
func waitStatus(t *testing.T, m *Mode, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
v, _ := m.View().(map[string]any)
|
||||
if v != nil && v["status"] == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("status never reached %q (last view: %+v)", want, m.View())
|
||||
}
|
||||
|
||||
func okBackend() stubBackend {
|
||||
return stubBackend{out: `{"next_action":"call mum","rationale":"people goal"}`}
|
||||
}
|
||||
|
||||
func TestConfirmFilesExactlyOneTask(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, okBackend())
|
||||
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
waitStatus(t, m, statusProposed)
|
||||
|
||||
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 ft.created[0] != "call mum" {
|
||||
t.Fatalf("created task title = %q, want %q", ft.created[0], "call mum")
|
||||
}
|
||||
if m.Active() {
|
||||
t.Fatal("mode should be done after confirm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissWritesNothing(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, okBackend())
|
||||
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
waitStatus(t, m, statusProposed)
|
||||
|
||||
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
|
||||
t.Fatalf("dismiss: %v", err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeErrorSurfacesInView(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, stubBackend{err: errors.New("boom")})
|
||||
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
waitStatus(t, m, statusError)
|
||||
|
||||
v, _ := m.View().(map[string]any)
|
||||
if v == nil || v["error"] == nil || v["error"] == "" {
|
||||
t.Fatalf("expected error field in view, got %+v", v)
|
||||
}
|
||||
if !m.Active() {
|
||||
t.Fatal("mode should stay active on error so the user can retry or dismiss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmWithoutProposalErrors(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, okBackend())
|
||||
// No start → no proposal.
|
||||
if err := m.Command(context.Background(), "confirm", nil); err == nil {
|
||||
t.Fatal("confirm without a proposal should error")
|
||||
}
|
||||
if len(ft.created) != 0 {
|
||||
t.Fatalf("confirm without proposal created %d tasks, want 0", len(ft.created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCommandErrors(t *testing.T) {
|
||||
m := newTestMode(t, &fakeTasks{}, okBackend())
|
||||
if err := m.Command(context.Background(), "bogus", nil); err == nil {
|
||||
t.Fatal("unknown command should error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user