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:
2026-06-05 18:00:49 -04:00
parent 258de2c14b
commit 7d69a1f320
57 changed files with 4069 additions and 627 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"path/filepath"
"strings"
"antidrift/internal/settings"
"keel/internal/settings"
"github.com/gin-gonic/gin"
)
@@ -81,7 +81,7 @@ type browseResponse struct {
// handleBrowse lists subdirectories plus .md files under dir, so the UI can build
// a file picker that returns a real server-side path. Dotfiles are NOT hidden —
// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail
// the default profile lives under ~/.keel. Localhost-only daemon; no jail
// beyond OS permissions (same trust boundary as the rest of the UI).
func (s *Server) handleBrowse(c *gin.Context) {
dir := strings.TrimSpace(c.Query("dir"))
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"testing"
"antidrift/internal/settings"
"keel/internal/settings"
)
// fakeApplier records the last settings it was asked to apply and can be told to
@@ -29,7 +29,7 @@ func (f *fakeApplier) apply(s settings.Settings) error {
}
func TestGetSettingsReturnsCurrent(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
@@ -51,7 +51,7 @@ func TestGetSettingsReturnsCurrent(t *testing.T) {
}
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{}
s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -78,7 +78,7 @@ func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
}
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{reject: true}
s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -97,7 +97,7 @@ func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
}
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
r := s.Router()
@@ -123,7 +123,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
t.Fatal(err)
}
s := newTestServer(t)
s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router()
@@ -179,7 +179,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
}
func TestBrowseBadDirIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
+14
View File
@@ -88,6 +88,10 @@ input[type="checkbox"] {
.btn-ghost { background: var(--line); color: var(--ink); }
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
/* Large tap targets for the launcher and off-screen primary actions. */
.btn-big { display: block; width: 100%; margin-top: 12px; padding: 14px 16px; font-size: 16px; }
.launcher .btn-big:first-of-type { margin-top: 4px; }
/* Drift/nudge actions live in the status band; lay them out below the text. */
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
@@ -176,3 +180,13 @@ input[type="checkbox"] {
font-family: ui-monospace, monospace;
}
.browse-list button:hover { background: var(--line); color: var(--accent); }
/* Off-screen mode: pending spinner. */
.offscreen-pending { text-align: center; }
.spinner {
width: 28px; height: 28px; border-radius: 50%;
border: 3px solid var(--line); border-top-color: var(--accent);
animation: spin 0.8s linear infinite; margin: 4px auto 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
+79 -12
View File
@@ -2,6 +2,7 @@ const app = document.getElementById('app');
const view = document.getElementById('view');
let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows
let renderedMode = null; // the active_mode the DOM currently shows
let dismissedNudge = null; // text of the nudge the user has dismissed
function post(path, body) {
@@ -86,9 +87,9 @@ function updateActiveDrift(state) {
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
</div>`;
document.getElementById('refocus').onclick = () => post('/refocus');
document.getElementById('ontask').onclick = () => post('/ontask');
document.getElementById('enddrift').onclick = () => post('/complete');
document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
return;
}
@@ -223,7 +224,7 @@ function updatePlanningReflection(refl) {
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
}
function render(state) {
function renderFocus(state) {
setStateAttr(state);
const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') {
@@ -248,7 +249,7 @@ function render(state) {
<p class="meta">No active commitment.</p>
<button id="plan" class="btn btn-primary">Start planning</button>
</div>`;
document.getElementById('plan').onclick = () => post('/planning');
document.getElementById('plan').onclick = () => post('/mode/focus/start');
} else if (rs === 'planning') {
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
@@ -276,7 +277,7 @@ function render(state) {
mins = document.getElementById('mins'), start = document.getElementById('start');
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
[na, sc, mins].forEach(el => el.oninput = check);
start.onclick = () => post('/commitment', {
start.onclick = () => post('/mode/command/commitment', {
next_action: na.value.trim(),
success_condition: sc.value.trim(),
timebox_secs: Math.round(+mins.value * 60),
@@ -286,7 +287,7 @@ function render(state) {
});
document.getElementById('sharpen').onclick = () => {
const intent = document.getElementById('intent').value.trim();
if (intent) post('/coach', { intent });
if (intent) post('/mode/command/coach', { intent });
};
updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks);
@@ -304,7 +305,7 @@ function render(state) {
</div>
${evidenceBlock(state.evidence)}
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
document.getElementById('done').onclick = () => post('/complete');
document.getElementById('done').onclick = () => post('/mode/command/complete');
const t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick();
@@ -322,16 +323,82 @@ function render(state) {
${reflectionBlock(state.reflection)}
${reviewSummary(state.evidence)}
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
document.getElementById('end').onclick = () => post('/end');
document.getElementById('end').onclick = () => post('/mode/command/end');
} else {
view.textContent = rs;
}
}
// renderLauncher shows the idle screen with mode-start buttons.
function renderLauncher() {
app.dataset.state = 'locked';
view.innerHTML = `<div class="band statusband"><span class="pill">Keel</span></div>
<div class="band launcher">
<p class="meta">No active mode.</p>
<button id="startFocus" class="btn btn-primary btn-big">Start focus</button>
<button id="startOffscreen" class="btn btn-ghost btn-big">Off-screen</button>
</div>`;
document.getElementById('startFocus').onclick = () => post('/mode/focus/start');
document.getElementById('startOffscreen').onclick = () => post('/mode/offscreen/start');
}
// renderOffscreen shows the off-screen mode card. Basic but functional; Task 22
// polishes the phone-first styling.
function renderOffscreen(m) {
app.dataset.state = 'review';
const status = m.status || 'pending';
let body;
if (status === 'proposed') {
body = `<div class="band">
<div class="action">${esc(m.next_action || '')}</div>
<p class="meta">${esc(m.rationale || '')}</p>
</div>
<div class="band band-actions">
<button id="osDo" class="btn btn-primary btn-big">Do it</button>
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
</div>`;
} else if (status === 'error') {
body = `<div class="band">
<p class="meta">${esc(m.error || 'Could not propose an action.')}</p>
</div>
<div class="band band-actions">
<button id="osRetry" class="btn btn-primary btn-big">Retry</button>
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
</div>`;
} else { // pending / done
body = `<div class="band offscreen-pending">
<div class="spinner" aria-hidden="true"></div>
<p class="meta">Finding a worthwhile off-screen action…</p>
</div>`;
}
view.innerHTML = `<div class="band statusband"><span class="pill">Off-screen</span></div>${body}`;
const doBtn = document.getElementById('osDo');
if (doBtn) doBtn.onclick = () => post('/mode/command/confirm');
const disBtn = document.getElementById('osDismiss');
if (disBtn) disBtn.onclick = () => post('/mode/command/dismiss');
const retryBtn = document.getElementById('osRetry');
if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start');
}
// route dispatches the state envelope to the active mode's renderer. On a mode
// switch it resets the focus in-place trackers so stale DOM never leaks across.
function route(env) {
const am = (env && env.active_mode) || '';
if (am !== renderedMode) {
renderedMode = am;
renderedState = null;
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
}
if (am === '') return renderLauncher();
if (am === 'focus') return renderFocus(env.mode || {});
if (am === 'offscreen') return renderOffscreen(env.mode || {});
view.textContent = am; // unknown mode: degrade visibly
}
const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
es.onmessage = (e) => route(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; };
// ---- Settings overlay ----
function openSettings() {
@@ -349,7 +416,7 @@ function openSettings() {
<input id="setMarvin" placeholder="uv run am">
<label>Knowledge profile path</label>
<div class="path-row">
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
<input id="setKnow" placeholder="~/.keel/knowledge.md">
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
</div>
<div id="browsePane" class="browse" hidden></div>
+2 -2
View File
@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AntiDrift</title>
<title>Keel</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<main id="app">
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings"></button></h1>
<h1>Keel <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings"></button></h1>
<div class="card" id="view">connecting…</div>
<div id="settingsOverlay" class="overlay" hidden></div>
</main>
+67 -89
View File
@@ -7,15 +7,15 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"sync"
"time"
"antidrift/internal/domain"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/statemachine"
"keel/internal/harness"
"keel/internal/mode/focus/statemachine"
"keel/internal/settings"
"github.com/gin-gonic/gin"
)
@@ -23,9 +23,9 @@ import (
//go:embed static/*
var staticFS embed.FS
// Server wires the session controller to HTTP and SSE.
// Server wires the harness to HTTP and SSE.
type Server struct {
ctrl *session.Controller
h *harness.Harness
bcast *Broadcaster
mu sync.Mutex
@@ -38,16 +38,20 @@ type Server struct {
applyMu sync.Mutex // serializes POST /settings apply+save
}
func NewServer(ctrl *session.Controller) *Server {
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()}
ctrl.SetOnChange(s.broadcast)
// NewServer wires the harness to the SSE broadcaster: every harness change
// (mode start, command, async role completion, expiry) fans out the current
// state envelope to subscribers. The callback is registered additively, so the
// status-file writer can register its own listener alongside this one.
func NewServer(h *harness.Harness) *Server {
s := &Server{h: h, bcast: NewBroadcaster()}
h.AddOnChange(s.broadcast)
return s
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// antidriftd binds localhost only and sits behind no proxy, so trust no
// keeld binds localhost only and sits behind no proxy, so trust no
// forwarded-IP headers. This also silences gin's trust-all-proxies warning.
_ = r.SetTrustedProxies(nil)
@@ -68,13 +72,8 @@ func (s *Server) Router() *gin.Engine {
c.FileFromFS("/favicon.png", http.FS(sub))
})
r.GET("/events", s.handleEvents)
r.POST("/planning", s.handlePlanning)
r.POST("/coach", s.handleCoach)
r.POST("/commitment", s.handleCommitment)
r.POST("/complete", s.handleComplete)
r.POST("/end", s.handleEnd)
r.POST("/refocus", s.handleRefocus)
r.POST("/ontask", s.handleOnTask)
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)
@@ -82,7 +81,7 @@ func (s *Server) Router() *gin.Engine {
}
func (s *Server) stateJSON() string {
data, _ := json.Marshal(s.ctrl.State())
data, _ := json.Marshal(s.h.State())
return string(data)
}
@@ -90,80 +89,58 @@ func (s *Server) broadcast() {
s.bcast.Publish(s.stateJSON())
}
// respond maps a controller error to an HTTP status, otherwise broadcasts and
// returns the new state.
// respond maps a harness/mode error to an HTTP status, otherwise returns the
// new state envelope. Broadcasting is handled solely by the harness onChange
// listeners registered in NewServer; callers must not broadcast here.
func (s *Server) respond(c *gin.Context, err error) {
if err != nil {
var illegal statemachine.IllegalTransitionError
if errors.As(err, &illegal) {
switch {
case errors.As(err, &illegal):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
case errors.Is(err, harness.ErrBusy):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, harness.ErrIdle):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, harness.ErrUnknownMode):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s.broadcast()
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
}
func (s *Server) handlePlanning(c *gin.Context) {
s.cancelExpiry()
s.respond(c, s.ctrl.EnterPlanning())
}
type coachRequest struct {
Intent string `json:"intent"`
}
func (s *Server) handleCoach(c *gin.Context) {
var req coachRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
s.respond(c, s.ctrl.RequestCoach(req.Intent))
}
type commitmentRequest 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"`
}
func (s *Server) handleCommitment(c *gin.Context) {
var req commitmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
level := domain.EnforcementWarn
if req.Enforce {
level = domain.EnforcementBlock
}
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
if err == nil {
// handleStart activates a mode by kind. Starting focus arms the expiry timer in
// case the mode is restored or transitions straight into a deadline-bearing
// state; the arm is a no-op when there is no deadline yet.
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) handleComplete(c *gin.Context) {
s.cancelExpiry()
s.respond(c, s.ctrl.Complete())
}
func (s *Server) handleEnd(c *gin.Context) {
s.respond(c, s.ctrl.End())
}
func (s *Server) handleRefocus(c *gin.Context) {
s.respond(c, s.ctrl.Refocus())
}
func (s *Server) handleOnTask(c *gin.Context) {
s.respond(c, s.ctrl.OnTask())
// handleCommand routes a surface command to the active mode and manages the
// expiry timer for the focus lifecycle: a commitment arms it, completing or
// ending cancels it. The raw body is forwarded as the command payload; modes
// decode and validate it themselves.
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)
}
func (s *Server) handleEvents(c *gin.Context) {
@@ -196,9 +173,12 @@ func (s *Server) handleEvents(c *gin.Context) {
}
}
// armExpiry schedules an Active -> Review transition at the commitment deadline.
// armExpiry schedules an Active -> Review transition at the active mode's
// deadline. A zero deadline (no deadline-bearing mode) is a no-op. On firing,
// Expire notifies harness listeners, which broadcasts the new state via the
// callback registered in NewServer.
func (s *Server) armExpiry() {
deadline := s.ctrl.Deadline()
deadline := s.h.Deadline()
if deadline.IsZero() {
return
}
@@ -212,9 +192,7 @@ func (s *Server) armExpiry() {
d = 0
}
s.timer = time.AfterFunc(d, func() {
if err := s.ctrl.Expire(); err == nil {
s.broadcast()
}
_ = s.h.Expire()
})
}
@@ -227,17 +205,17 @@ func (s *Server) cancelExpiry() {
}
}
// Init re-arms or expires a restored Active session at startup.
// Init re-arms or expires a restored deadline-bearing session at startup. With
// no active mode the deadline is zero, so this is a no-op until a session is
// adopted.
func (s *Server) Init() {
st := s.ctrl.State()
if st.RuntimeState != "active" {
deadline := s.h.Deadline()
if deadline.IsZero() {
return
}
if s.ctrl.Deadline().After(time.Now()) {
if deadline.After(time.Now()) {
s.armExpiry()
return
}
if err := s.ctrl.Expire(); err == nil {
s.broadcast()
}
_ = s.h.Expire()
}
+190 -88
View File
@@ -9,26 +9,46 @@ import (
"testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/tasks"
"keel/internal/ai"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
"keel/internal/settings"
"keel/internal/tasks"
"github.com/gin-gonic/gin"
)
func newTestServer(t *testing.T) *Server {
// newTestServer builds a harness around a freshly-constructed focus mode and
// returns the server plus the mode so tests can inject per-role stubs (the old
// controller's Set* API lives on focus.Mode) and assert on the typed focus
// state. The mode is registered behind a factory that wires the harness change
// notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on
// every focus change. The mode starts Locked; tests drive it through the
// command routes (e.g. POST /mode/command/planning), mirroring the original
// suite which POSTed /planning first.
func newTestServer(t *testing.T) (*Server, *focus.Mode) {
t.Helper()
gin.SetMode(gin.TestMode)
path := filepath.Join(t.TempDir(), "state.json")
ctrl, err := session.New(path)
m, err := focus.New(path)
if err != nil {
t.Fatalf("controller: %v", err)
t.Fatalf("focus.New: %v", err)
}
return NewServer(ctrl)
h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{
"focus": func(svc harness.Services) mode.Mode {
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m
},
})
s := NewServer(h)
if err := h.Start("focus"); err != nil {
t.Fatalf("harness.Start(focus): %v", err)
}
return s, m
}
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
@@ -40,53 +60,73 @@ func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRec
return w
}
// fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for
// the real-factory parity test without reaching any model.
type fakeBackend struct{}
func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil }
func (fakeBackend) Name() string { return "fake" }
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
if m.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", m.State().RuntimeState)
}
}
func TestCommitmentRejectsInvalidInput(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
w := post(t, r, "/commitment", body)
w := post(t, r, "/mode/command/commitment", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestIllegalTransitionReturns409(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
// /complete from Locked is illegal.
w := post(t, r, "/complete", "")
w := post(t, r, "/mode/command/complete", "")
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
// TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with
// the focus state nested under "mode" and the kind under "active_mode".
func TestEnvelopeShape(t *testing.T) {
s, _ := newTestServer(t)
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("payload missing active_mode: %s", js)
}
if !strings.Contains(js, `"mode"`) {
t.Fatalf("payload missing nested mode object: %s", js)
}
}
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// A focus update should appear in the serialized state.
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}})
// A focus update should appear in the serialized state envelope.
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
js := s.stateJSON()
if !strings.Contains(js, `"evidence"`) {
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
}
func TestLockedStateHasNullEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
// End any active session to return to Locked; a freshly-started focus mode is
// already Locked (Start does not auto-plan in this harness wiring).
if m.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState)
}
js := s.stateJSON()
if !strings.Contains(js, `"evidence":null`) {
t.Fatalf("locked payload should have null evidence: %s", js)
@@ -115,60 +160,60 @@ func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Prop
}
func TestCoachRouteHappyPath(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
s, m := newTestServer(t)
m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" {
if cv := m.State().Coach; cv != nil && cv.Status == "ready" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach)
t.Fatalf("coach never reached ready: %+v", m.State().Coach)
}
func TestCoachRouteOutsidePlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{})
s, m := newTestServer(t)
m.SetCoach(stubCoach{})
r := s.Router()
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCoachRouteInvalidJSON(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCoachRouteUnavailableDegrades(t *testing.T) {
s := newTestServer(t) // no SetCoach
s, m := newTestServer(t) // no SetCoach
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
t.Fatalf("unavailable coach should still 200, got %d", w.Code)
}
if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" {
if cv := m.State().Coach; cv == nil || cv.Status != "error" {
t.Fatalf("want error status, got %+v", cv)
}
}
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest {
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
}
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest {
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
}
}
@@ -180,22 +225,22 @@ func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles [
}
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
s, m := newTestServer(t)
m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// Two distinct on-task titles trip the nudge (history >= 2; first call has no
// debounce to wait on).
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
if d := m.State().Drift; d != nil && d.Nudge != "" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
}
func TestServesStaticAssets(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
cases := []struct {
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return s.list, nil
}
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
s, m := newTestServer(t)
m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
if tv := m.State().Tasks; tv != nil && tv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
}
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
}
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
t.Fatalf("commitment did not carry allowed classes: %#v", got)
}
// Now Active: overrides succeed.
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK {
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
}
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK {
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
}
}
@@ -298,15 +345,15 @@ func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, e
}
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}})
s, m := newTestServer(t)
m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" {
if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -329,20 +376,20 @@ func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.
}
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
s, m := newTestServer(t)
m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" {
if rv := m.State().Reflection; rv != nil && rv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
t.Fatalf("review payload missing recap: %s", js)
}
// End -> Locked -> Planning: the carry-forward should surface on planning.
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
// End -> Locked: the harness releases the focus mode and goes idle. Re-start
// the mode (re-adopting the same in-memory focus, carry-forward intact) and
// re-enter Planning; the carry-forward should surface there.
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
js2 := s.stateJSON()
@@ -372,19 +424,19 @@ func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title stri
}
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
m.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -397,15 +449,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
}
func TestKnowledgePathSelectionReloads(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s, m := newTestServer(t)
m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
settings.Settings{}, func(ss settings.Settings) error {
s.ctrl.SetKnowledgePath(ss.KnowledgePath)
m.SetKnowledgePath(ss.KnowledgePath)
return nil
})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK {
@@ -413,10 +465,60 @@ func TestKnowledgePathSelectionReloads(t *testing.T) {
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
if kv := m.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge)
t.Fatalf("path selection did not reload: %+v", m.State().Knowledge)
}
// TestRealFocusFactoryParity drives the REAL focus.Factory through the harness
// (Start -> commitment -> complete -> end), proving the generalized routes work
// end-to-end against a focus mode the harness itself constructs from Services —
// not a pre-built mode injected by the test. focus.Factory auto-enters Planning
// on Start, so no explicit planning command is needed.
func TestRealFocusFactoryParity(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := harness.Services{
AI: ai.NewService(fakeBackend{}),
Clock: time.Now,
Dir: t.TempDir(),
}
h := harness.New(svc, map[string]harness.Factory{"focus": focus.Factory})
s := NewServer(h)
r := s.Router()
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
// Start auto-plans; the envelope should report focus active and Planning.
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("envelope missing active_mode focus: %s", js)
}
if !strings.Contains(js, `"runtime_state":"planning"`) {
t.Fatalf("expected planning after start: %s", js)
}
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"active"`) {
t.Fatalf("expected active after commitment: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"review"`) {
t.Fatalf("expected review after complete: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
// End drives focus to Locked; the harness releases it and goes idle.
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after end ActiveMode = %q, want idle", got)
}
}