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
+1 -1
View File
@@ -2,5 +2,5 @@
.superpowers/ .superpowers/
# Go # Go
/antidriftd /keeld
*.test *.test
+6 -7
View File
@@ -17,7 +17,7 @@ acts on the reply.
DB / event store. Buckets hold *pointers*, not copies. DB / event store. Buckets hold *pointers*, not copies.
- **Frame** — `~/owc` (values, goals, `resources/bug-*.md` life-bugs). Reference, - **Frame** — `~/owc` (values, goals, `resources/bug-*.md` life-bugs). Reference,
never duplicate. never duplicate.
- **Surfaces** — a phone-friendly web UI + the WM status bar (`~/.antidrift_status`). - **Surfaces** — a phone-friendly web UI + the WM status bar (`~/.keel_status`).
- **Effectors** — gated writes (Marvin tasks, capture, AntiDrift enforce; later - **Effectors** — gated writes (Marvin tasks, capture, AntiDrift enforce; later
Beeminder datapoints, `~/owc` notes). Start propose-and-confirm; trend toward Beeminder datapoints, `~/owc` notes). Start propose-and-confirm; trend toward
autonomy. The gate is per-effector config — Felix's agency-vs-cage dial. autonomy. The gate is per-effector config — Felix's agency-vs-cage dial.
@@ -31,17 +31,16 @@ acts on the reply.
general *collect → brain → act* loop where a focus-session is one mode among many. general *collect → brain → act* loop where a focus-session is one mode among many.
```bash ```bash
go run ./cmd/antidriftd # local web UI at http://localhost:7777 go run ./cmd/keeld # local web UI at http://localhost:7777
go test ./... go test ./...
``` ```
## Naming / rename status ## Naming / rename status
The directory and git repo are **`keel`**. The **code identity is intentionally The rename is **complete**. Directory, git repo, Go module, binary, runtime, and
still `antidrift`** — Go module `antidrift`, `antidriftd` binary, `~/.antidrift/` env are all `keel`: module `keel`, binary `keeld`, runtime `~/.keel/`,
runtime, `ANTIDRIFT_*` env. The code rename lands later, *with a state migration* `KEEL_*` env. If you have a live ledger under the old `~/.antidrift/` directory,
(`~/.antidrift/` owns the live ledger), during the controller refactor. Don't move it manually to `~/.keel/` — there is no automated migration tool.
`mv ~/.antidrift` or rename the module casually.
## How to work here ## How to work here
+25 -15
View File
@@ -9,27 +9,37 @@ effectors — surfaced on a web UI and the WM status bar. The brain, the storage
> **Architecture:** [`docs/keel-architecture.md`](docs/keel-architecture.md) is the > **Architecture:** [`docs/keel-architecture.md`](docs/keel-architecture.md) is the
> source of truth. **Agents:** read [`AGENTS.md`](AGENTS.md) first. > source of truth. **Agents:** read [`AGENTS.md`](AGENTS.md) first.
## Focus mode (AntiDrift) ## Modes
What runs in this repo *today* is **AntiDrift**, Keel's first mode: a personal Keel's controller is now a generic **harness** that hosts one *mode* at a time,
focus operating system that treats each work session as an explicit commitment and the daemon ships two:
(next action, success condition, timebox) and makes drift visible. Keel
generalizes its controller so a focus-session becomes one mode among several
(house, body, review, capture). The milestone history below is this mode's record.
> **Naming:** the directory and git repo are renamed to `keel`; the *code identity* - **Focus (AntiDrift)** — Keel's first mode: a personal focus operating system
> is intentionally still `antidrift` (Go module, `antidriftd` binary, `~/.antidrift/` that treats each work session as an explicit commitment (next action, success
> runtime, `ANTIDRIFT_*` env). The code rename lands with a state migration during condition, timebox) and makes drift visible. The milestone history below is
> the controller refactor — see `docs/keel-architecture.md` §8. this mode's record.
- **Off-screen** — an away-from-the-desk mode: it reads today's Marvin tasks plus
the `~/owc` goals and life-domain `bug-*.md` notes, asks the brain for one
worthwhile off-screen action, and on confirm files it as a Marvin task.
When no mode is active the web UI shows a **launcher** to start either one.
Further modes (body, review, capture) are aspirational. The milestone history
below records the focus mode's development.
> **Naming:** the rename is complete — directory, repo, Go module, binary,
> runtime, and env are all `keel` (`keeld` binary, `~/.keel/` runtime,
> `KEEL_*` env). If you have a live ledger under the old `~/.antidrift/`
> directory, move it manually to `~/.keel/`.
## Run ## Run
```bash ```bash
go run ./cmd/antidriftd go run ./cmd/keeld
``` ```
The daemon serves a local web UI at http://localhost:7777 and opens your The daemon serves a local web UI at http://localhost:7777 and opens your
browser. State is persisted to `~/.antidrift/state.json`. browser. Per-mode state is persisted under `~/.keel/modes/<kind>/` (e.g.
`~/.keel/modes/focus/state.json`).
## Test ## Test
@@ -57,8 +67,8 @@ into the coach's grounding; the reflection lines are short and cross the wire
by design, while the knowledge profile still does not. by design, while the knowledge profile still does not.
M6 (knowledge port): the planning coach now sharpens intents against who you M6 (knowledge port): the planning coach now sharpens intents against who you
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable actually are. A single profile file (`~/.keel/knowledge.md`, overridable
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities, via `KEEL_KNOWLEDGE_FILE`) holds your standing context — priorities,
values, what counts as good work — and the daemon loads it asynchronously on values, what counts as good work — and the daemon loads it asynchronously on
entering planning, mirroring the tasks fetch. The cached text grounds the AI entering planning, mirroring the tasks fetch. The cached text grounds the AI
coach prompt only; the drift judge and nudge are untouched, and the profile text coach prompt only; the drift judge and nudge are untouched, and the profile text
@@ -86,7 +96,7 @@ The drift judge degrades gracefully — without it, local matching still runs.
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened" M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
into a structured commitment (next action, success condition, timebox) by an into a structured commitment (next action, success condition, timebox) by an
LLM CLI backend (claude or codex, selectable via `ANTIDRIFT_AI_BACKEND`). The LLM CLI backend (claude or codex, selectable via `KEEL_AI_BACKEND`). The
coach runs asynchronously and degrades gracefully — manual planning always coach runs asynchronously and degrades gracefully — manual planning always
works. works.
-142
View File
@@ -1,142 +0,0 @@
// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI
// and owns the commitment state machine.
package main
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"time"
"antidrift/internal/ai"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/statusfile"
"antidrift/internal/store"
"antidrift/internal/tasks"
"antidrift/internal/web"
)
const addr = "localhost:7777"
func main() {
path, err := store.DefaultPath()
if err != nil {
log.Fatalf("resolve snapshot path: %v", err)
}
ctrl, err := session.New(path)
if err != nil {
log.Fatalf("load session: %v", err)
}
srv := web.NewServer(ctrl)
srv.Init() // re-arm or expire a restored Active session
// Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// The knowledge source is a single file adapter whose path is driven entirely
// by SetKnowledgePath, so startup and live settings edits share one code path.
ctrl.SetKnowledge(knowledge.NewFileSource(""))
// applyFn re-wires the running daemon from a Settings value: AI backend +
// service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It
// validates the backend FIRST and mutates nothing on failure, so POST /settings
// can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
ctrl.SetCoach(svc)
ctrl.SetDriftJudge(svc)
ctrl.SetNudge(svc)
ctrl.SetReviewer(svc)
ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))
ctrl.SetKnowledgePath(s.KnowledgePath)
return nil
}
// Apply once at startup. A bad persisted backend should not be fatal: fall back
// to claude (always valid), persist the correction, and re-apply.
if err := applyFn(cfg); err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
if err := applyFn(cfg); err != nil {
log.Fatalf("settings: apply failed even with claude: %v", err)
}
}
srv.SetSettings(settingsPath, cfg, applyFn)
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, ctrl.State)
// Re-render the bar on every state change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
ctrl.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
ctrl.SetGuard(enforce.NewGuard())
log.Printf("enforce: window-minimize guard")
src := evidence.NewSource()
go src.Watch(context.Background(), ctrl.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("antidriftd listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+188
View File
@@ -0,0 +1,188 @@
// Command keeld is the Keel daemon: it serves a local web UI and drives the
// harness — the general collect→brain→act loop that hosts one mode at a time
// (focus or offscreen).
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/offscreen"
"keel/internal/settings"
"keel/internal/statusfile"
"keel/internal/tasks"
"keel/internal/web"
)
const addr = "localhost:7777"
func main() {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("resolve home dir: %v", err)
}
root := filepath.Join(home, ".keel")
// Resolve and load settings, seeding from the legacy KEEL_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// buildServices turns a Settings value into a fresh harness.Services. It
// validates the AI backend FIRST and returns an error before constructing any
// ports, so an invalid backend never half-rewires the harness. The same code
// path serves startup and live POST /settings edits.
buildServices := func(s settings.Settings) (harness.Services, error) {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
return harness.Services{
AI: svc,
Tasks: tasks.NewMarvin(s.MarvinCmd),
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
Enforce: enforce.NewGuard(),
Clock: time.Now,
}, nil
}
// Build the base services at startup. A bad persisted backend should not be
// fatal: fall back to claude (always valid), persist the correction, and retry.
base, err := buildServices(cfg)
if err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
base, err = buildServices(cfg)
if err != nil {
log.Fatalf("settings: build failed even with claude: %v", err)
}
}
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
log.Printf("enforce: window-minimize guard")
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
// mode builds its own file paths under Services.Dir.
withDir := func(dir string, f harness.Factory) harness.Factory {
return func(s harness.Services) mode.Mode {
s.Dir = dir
return f(s)
}
}
focusDir := filepath.Join(root, "modes", "focus")
offscreenDir := filepath.Join(root, "modes", "offscreen")
h := harness.New(base, map[string]harness.Factory{
"focus": withDir(focusDir, focus.Factory),
"offscreen": withDir(offscreenDir, offscreen.Factory),
})
// Re-adopt a live focus session restored from disk, if one was in flight.
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
// restored mode's async completions reach the harness's onChange listeners.
focusSvc := h.Services()
focusSvc.Dir = focusDir
if m, live, err := focus.Restore(focusSvc); err != nil {
log.Printf("focus: restore failed (continuing idle): %v", err)
} else if live {
if err := h.Adopt(m); err != nil {
log.Printf("focus: adopt restored session failed: %v", err)
} else {
log.Printf("focus: restored a live session")
}
}
srv := web.NewServer(h)
srv.Init() // re-arm or expire a restored deadline-bearing session
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
// fresh harness.Services and installs it via SetServices. Per the harness
// contract this affects the NEXT mode start; an already-active mode keeps the
// services it was built with. It validates the backend before mutating, so
// POST /settings can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
next, err := buildServices(s)
if err != nil {
return err
}
h.SetServices(next)
return nil
}
srv.SetSettings(settingsPath, cfg, applyFn)
// Mirror runtime status to ~/.keel_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, h.State)
// Re-render the bar on every harness change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
h.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
// Feed the window sensor stream into the harness, which forwards it to the
// active mode iff that mode consumes evidence. On a headless box the source is
// a no-op, so this degrades gracefully without new X11 requirements.
src := evidence.NewSource()
go src.Watch(context.Background(), h.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("keeld listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+56 -41
View File
@@ -18,12 +18,11 @@ status: authoritative
> **Name & home.** *Keel* is the system: a human-harness that helps Felix hold > **Name & home.** *Keel* is the system: a human-harness that helps Felix hold
> course toward his higher goals. The name answers AntiDrift's metaphor — *drift* > course toward his higher goals. The name answers AntiDrift's metaphor — *drift*
> is what a vessel does with nothing to hold it; the *keel* resists drift and > is what a vessel does with nothing to hold it; the *keel* resists drift and
> keeps you on an even keel (steady mind). **This repo is now `keel`** — AntiDrift's > keeps you on an even keel (steady mind). **This repo is `keel`** — AntiDrift's
> focus harness becomes Keel's first *mode*. The directory and git repo are > focus harness becomes Keel's first *mode*. The rename is **complete**: directory,
> renamed; the **code identity is intentionally still `antidrift`** (Go module > git repo, Go module (`keel`), binary (`keeld`), runtime (`~/.keel/`), and env
> `antidrift`, binary `antidriftd`, runtime `~/.antidrift/`, `ANTIDRIFT_*` env). > (`KEEL_*`) are all `keel`. The old `~/.antidrift/` ledger needs a one-time manual
> That *code* rename is **deferred** until the controller refactor (§8) because > move to `~/.keel/` for existing installs.
> `~/.antidrift/` owns the live ledger/state and needs a migration, not a `mv`.
> Moved from `~/dev/higher/` on 2026-06-04 — that directory was the scaffold where > Moved from `~/dev/higher/` on 2026-06-04 — that directory was the scaffold where
> the decision to extend AntiDrift was made, and now retires. > the decision to extend AntiDrift was made, and now retires.
@@ -54,13 +53,15 @@ operator.
2. **Reuse AntiDrift's engine; loosen its controller.** AntiDrift already is this 2. **Reuse AntiDrift's engine; loosen its controller.** AntiDrift already is this
harness for one plane. Its controller is welded to *commitment = next_action + harness for one plane. Its controller is welded to *commitment = next_action +
success_condition + timebox*. We loosen that into a general **collect → brain → success_condition + timebox*. We loosen that into a general **collect → brain →
act** loop where a focus-session is just **one mode**. Other modes: house, act** loop where a focus-session is just **one mode**. The harness now hosts a
body, review, capture, planning. The core architecture is salvageable — we second, **off-screen** mode; further modes (body, review, capture) are
widen scope, we don't rebuild. aspirational. The core architecture is salvageable — we widen scope, we don't
- Worked example (a non-focus mode): from the phone web UI, *"what work on the rebuild.
house should I do next?"* → the harness reads the house bugs/tasks, the brain - Worked example (the shipped non-focus mode): from the phone web UI, *"what
proposes a strategy, the UI asks Felix for feedback, and on approval an worthwhile off-screen thing should I do now?"* → the harness reads today's
effector files the task. Marvin tasks + the `~/owc` goals and life-domain `bug-*.md`, the brain
proposes one off-screen action, the UI asks Felix for feedback, and on
approval the Marvin effector files the task.
3. **Storage = ActivityWatch.** AW is already a general append-only event store 3. **Storage = ActivityWatch.** AW is already a general append-only event store
with a REST API, and it is currently the single richest *unused* asset with a REST API, and it is currently the single richest *unused* asset
@@ -72,7 +73,7 @@ operator.
- **Web UI** — configure, interact, discuss; phone-friendly. The place Felix - **Web UI** — configure, interact, discuss; phone-friendly. The place Felix
*talks to* Keel. *talks to* Keel.
- **WM status bar** — reflect current status (AntiDrift already writes - **WM status bar** — reflect current status (AntiDrift already writes
`~/.antidrift_status`). The reminder sentence lives here; it is one *status `~/.keel_status`). The reminder sentence lives here; it is one *status
element*, **not** a deliverable. (No "nice summaries" as a product.) element*, **not** a deliverable. (No "nice summaries" as a product.)
5. **Life-information root = `~/owc`** (owncloud markdown), formerly called 5. **Life-information root = `~/owc`** (owncloud markdown), formerly called
@@ -115,7 +116,7 @@ Each component has one job, a defined interface, and can be built/tested alone:
- **① Collectors** — one small read-only adapter per source. Contract: - **① Collectors** — one small read-only adapter per source. Contract:
`read(window) -> Signal[]`. Today's real interfaces: AW `POST :5600/api/0/query/`; `read(window) -> Signal[]`. Today's real interfaces: AW `POST :5600/api/0/query/`;
AntiDrift `tail ~/.antidrift/audit.jsonl` (+ live SSE `:7777/events`); HQ AntiDrift `tail ~/.keel/audit.jsonl` (+ live SSE `:7777/events`); HQ
read-only `hq.db` (+ `:8765`); Consume `GET :8000/api/...`; daily read-only read-only `hq.db` (+ `:8765`); Consume `GET :8000/api/...`; daily read-only
`daily.db` (+ `:2200`); Beeminder `beeline`; Marvin `ammcp` MCP / `am --json`. `daily.db` (+ `:2200`); Beeminder `beeline`; Marvin `ammcp` MCP / `am --json`.
Depends only on the source being up; degrades to a dropped signal if not. Depends only on the source being up; degrades to a dropped signal if not.
@@ -168,33 +169,38 @@ Keel is AntiDrift's port set, widened. The parts already exist:
| Brain | `ai.Backend` → local `claude`/`codex` | + Hermes (remote); pluggable interface | | Brain | `ai.Backend` → local `claude`/`codex` | + Hermes (remote); pluggable interface |
| Memory | ephemeral (dies in `state.json`) | **AW buckets** (durable, queryable) | | Memory | ephemeral (dies in `state.json`) | **AW buckets** (durable, queryable) |
| Effectors | `enforce.Guard` (window-minimize) | + Marvin, capture, Beeminder, `~/owc` notes | | Effectors | `enforce.Guard` (window-minimize) | + Marvin, capture, Beeminder, `~/owc` notes |
| Surfaces | web UI `:7777` + `~/.antidrift_status` | richer web UI (phone) + status bar | | Surfaces | web UI `:7777` + `~/.keel_status` | richer web UI (phone) + status bar |
| Control loop | session state machine (locked/planning/active/review) | **modes** (focus is one); a session is one mode | | Control loop | session state machine (locked/planning/active/review) | **modes** (focus is one); a session is one mode |
**Loosening the controller** concretely means: generalize `Commitment` from a **Loosening the controller** concretely means: generalize `Commitment` from a
work-session into a *mode invocation* (a mode has its own collectors, frame slice, work-session into a *mode invocation* (a mode has its own collectors, frame slice,
brain prompt, and allowed effectors); the focus-session remains the brain prompt, and allowed effectors); the focus-session remains the
`next_action/success_condition/timebox` mode; a "house" mode reads house bugs and `next_action/success_condition/timebox` mode; the off-screen mode reads the
proposes a strategy; a "capture" mode just routes a thought. The runtime state life-domain bugs + goals and proposes one off-screen action; a future "capture"
machine becomes the loop in §3. mode would just route a thought. The runtime state machine becomes the loop in §3.
--- ---
## 5. One loop, concrete ## 5. One loop, concrete
House mode, from the phone, end to end: Off-screen mode, from the phone, end to end**this is what ships today**:
1. **Collect**`~/owc/resources/bug-*.md` house items + Marvin "house" tasks + 1. **Collect**today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md`
(memory) what was proposed/done last weekend. goals + the life-domain `~/owc/resources/bug-*.md` notes, assembled into a
2. **Remember** — read Keel's AW `keel.state` bucket for prior house proposals so single budgeted brief.
it doesn't repeat itself. 2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen
3. **Assemble** — those + the `house-integrity` goal from `~/owc/goals-2026.md`. action plus a short rationale.
4. **Brain**chosen brain returns a proposed next house task + short strategy. 3. **Surface**the phone-first web UI renders a proposal card and asks Felix to
5. **Surface** — web UI shows the proposal and asks for feedback. confirm or dismiss.
6. **Act** — on approval, the Marvin effector files the task; Keel writes an 4. **Act** — on confirm, the Marvin `Create` effector files the action as a task
`action_taken` event to its AW bucket (memory closes the loop). (CRDT-preserving `am add`); the mode is one-shot and the harness returns idle.
No new store touched. The brain was rented for one call. Everything else was Keel. No new store touched. The brain was rented for one call. Everything else was Keel.
**Next increment:** memory. Off-screen does not yet read or write Keel's AW
buckets, so it cannot remember what it proposed last time. Adding a *remember*
step (read `keel.state` for prior proposals) and an *act* event
(`proposal_made`/`action_taken` to `keel.events`) closes the loop per §7.
--- ---
## 6. Sources & targets (the real ecosystem) ## 6. Sources & targets (the real ecosystem)
@@ -202,7 +208,7 @@ No new store touched. The brain was rented for one call. Everything else was Kee
| Tool | Read | Write | Role | Source of truth | | Tool | Read | Write | Role | Source of truth |
|---|---|---|---|---| |---|---|---|---|---|
| ActivityWatch | `POST :5600/api/0/query/`, sqlite | new buckets via REST | sensor **+ store** | `peewee-sqlite.v2.db` | | ActivityWatch | `POST :5600/api/0/query/`, sqlite | new buckets via REST | sensor **+ store** | `peewee-sqlite.v2.db` |
| AntiDrift | `audit.jsonl`, SSE `:7777` | `POST :7777` cmds, enforce | focus mode + enforcement | `~/.antidrift/` | | AntiDrift | `audit.jsonl`, SSE `:7777` | `POST :7777` cmds, enforce | focus mode + enforcement | `~/.keel/` |
| HQ | `hq.db` r/o, `:8765` | `dashboard.write_artifact` | agent-execution evidence | `hq.db` (~23k events) | | HQ | `hq.db` r/o, `:8765` | `dashboard.write_artifact` | agent-execution evidence | `hq.db` (~23k events) |
| Consume | `GET :8000/api` | (later) | intake/reflection signal | item frontmatter | | Consume | `GET :8000/api` | (later) | intake/reflection signal | item frontmatter |
| daily | `daily.db` r/o, `:2200` | — | mood/habit checkout | `daily.db` | | daily | `daily.db` r/o, `:2200` | — | mood/habit checkout | `daily.db` |
@@ -246,15 +252,22 @@ behavioral story) or left out initially.
- **Capture unification** — which single file is canonical (resolve - **Capture unification** — which single file is canonical (resolve
`~/owc/stream.md` vs `~/wrk/pkb/stream.md` vs dead inboxes) and rewiring `blurt` `~/owc/stream.md` vs `~/wrk/pkb/stream.md` vs dead inboxes) and rewiring `blurt`
to append there. to append there.
- **Controller refactor shape** — how invasive the AntiDrift generalization is; do
we branch the daemon or grow it in place. **This is also when the *code* rename
lands** — Go module / `antidriftd` binary / `ANTIDRIFT_*` env and
`~/.antidrift/``~/.keel/` with a state migration. (The directory is already
`keel`; only the code identity still says `antidrift`.)
- **Cross-device AW** — sync work-laptop AW or not (see §7). - **Cross-device AW** — sync work-laptop AW or not (see §7).
- **Secret hygiene** — an increasingly autonomous operator runs as the full Unix - **Secret hygiene** — an increasingly autonomous operator runs as the full Unix
user; plaintext keys exist on disk; the privacy boundary in ③ must be real. user; plaintext keys exist on disk; the privacy boundary in ③ must be real.
### Resolved
- **Code rename** — complete as of Phase 0 of the controller refactor. Go module
is `keel`, binary is `keeld`, runtime is `~/.keel/`, env prefix is `KEEL_*`.
Existing installs with a live ledger under `~/.antidrift/` need a one-time
manual move to `~/.keel/`.
- **Controller refactor shape** — resolved: grown in place, not branched. The
focus-only `session.Controller` is now a generic `harness.Harness` that hosts
one `mode.Mode` at a time (the `collect → brain → act` loop of §3); focus is the
first mode and off-screen the second. Per-mode persistence lives under
`~/.keel/modes/<kind>/`.
--- ---
## 9. What this supersedes (from `~/dev/higher/`) ## 9. What this supersedes (from `~/dev/higher/`)
@@ -278,9 +291,11 @@ behavioral story) or left out initially.
## 10. Smallest real slice (a vertical, not a summary) ## 10. Smallest real slice (a vertical, not a summary)
To stay honest to "ship visible proof" without boiling the ocean: the first slice To stay honest to "ship visible proof" without boiling the ocean, the first slice
is **house mode, end to end** (§5) on the existing AntiDrift web surface — one is **off-screen mode, end to end** (§5) on the existing AntiDrift web surface —
collector (`~/owc` house bugs + Marvin), the brain, the web UI proposal, and the collectors (`~/owc` goals + life-domain bugs + Marvin), the brain (`ai.Proposer`),
Marvin effector behind a confirm gate. It exercises every Keel component once, on the phone-first web UI proposal card, and the Marvin `Create` effector behind a
the smallest mode, and produces a real filed task — interaction and action, not a confirm gate. It exercises every Keel component once except memory, on the
coaching sentence. smallest mode, and produces a real filed task — interaction and action, not a
coaching sentence. **This slice now ships;** durable AW memory (§7) is the next
increment that closes the loop.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
# Controller Refactor — Harness + Modes — Design
**Date:** 2026-06-04
**Status:** Approved, ready for implementation planning
**Scope:** Rewrite the focus-only session controller into a generic
**harness** that hosts swappable **modes**. Focus becomes the first mode
(behavior-preserving extraction); **off-screen mode** becomes the second
(new, the proof the abstraction generalizes). The `antidrift``keel` code
rename and `~/.antidrift/``~/.keel/` runtime move land in the same pass,
clean-slate (no state migration).
## Problem
`session.Controller` (≈1,400 LOC across `session.go`, `roles.go`, `drift.go`,
`stats.go`, `views.go`) is a god-object. It conflates three things:
1. **Generic runtime orchestration** — holding the ports, the async
brain-call primitive (`runFetchAsync`), persistence, change-notify, the
evidence ingestion callback, the clock.
2. **The focus state machine**`locked→planning→active→transition→review`,
commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence
stats, reflection, session sealing.
3. **The focus domain payload**`domain.Commitment`
(`next_action / success_condition / timebox`), sitting in a package named
`domain` as if it were generic.
Focus mode is not *a* mode — it **is** the controller. There is no seam to add
a second mode. Keel's thesis (the harness is the product; focus is one mode
among many) cannot be expressed until (1) is separated from (2)+(3).
The directory and git repo are already `keel`; only the code identity still
says `antidrift`. AntiDrift was never in production and its `~/.antidrift/`
ledger is disposable, so the rename and runtime move can be done outright with
no migration ceremony.
## Goals
- A generic `internal/harness` host that owns shared services and exactly one
active mode, and routes I/O to it.
- A small `Mode` contract that holds both a long-lived stateful mode (focus)
and a one-shot request/response mode (off-screen) without crippling either.
- Focus re-homed as `mode/focus` with **identical behavior** — existing focus
tests are the regression oracle.
- Off-screen mode built end-to-end: collect → assemble → brain → surface →
gated Marvin write.
- The `antidrift``keel` rename complete in code and runtime, clean-slate.
## Non-goals (YAGNI)
- **AW-bucket memory** (the §7 "remember across runs" layer). Off-screen v1 is
stateless. AW memory is the immediate fast-follow, its own spec.
- **Concurrent / ambient modes.** Exactly one active mode at a time
(idle ↔ focus *or* off-screen). Matches today's single-commitment reality.
- **Any third mode.**
- **State migration** from `~/.antidrift/`. Clean slate by decision.
- Renaming the off-screen mode's working code name (`offscreen`) — placeholder,
rename freely later.
## Decisions that shaped this design
- **One thin `Mode` interface + capability interfaces**, not a stateless
collect/assemble/effector pipeline framework (Procrustean for focus's
stateful machine, and a YAGNI framework for two modes), and not two formal
mode *kinds* (fragments the concept before the seams are felt). Capability
interfaces (`EvidenceConsumer`, `Expirer`) give the heterogeneity honesty
without the taxonomy. If a third one-shot mode appears, off-screen's flow can
be promoted into a real pipeline helper then — driven by evidence.
- **Generalize the web routes** (`/mode/{kind}/start`, `/mode/command/{name}`)
rather than keep focus's bespoke endpoints. The frontend is rewritten under
the state envelope regardless.
- **`Locked` collapses into harness idle.** "No active mode" is the resting
state; focus is `Planning→Active→Review→done`. One fewer state.
- **Off-screen reuses the existing single-file `knowledge.Source`** for the
`~/owc/goals-2026.md` frame, plus a small in-package glob read
(`filepath.Glob` + read) for the curated life-bug set. `knowledge.Source.Load`
is single-path by contract; no new adapter package — smallest real slice.
- **The confirm click is the effector gate.** No auto-write in v1.
## Package layout
```
cmd/keeld/main.go # wires harness + modes + surfaces + settings
internal/harness/
harness.go # Harness: services, the one active mode, change-notify, store root
services.go # Services handle passed to modes
async.go # generation-guarded async primitive (was runFetchAsync)
internal/mode/
mode.go # Mode interface, capability interfaces, Envelope
internal/mode/focus/ # today's session/* + domain/* + statemachine/* re-homed
focus.go statemachine.go drift.go stats.go views.go roles.go ...
internal/mode/offscreen/
offscreen.go # NEW: off-screen mode
internal/ai/ tasks/ knowledge/ evidence/ enforce/ # shared ports — only module path changes
internal/store/ # persistence primitives + per-mode namespacing
internal/settings/ statusfile/ web/ # web becomes mode-aware
```
`internal/domain` dissolves into `mode/focus`: `Commitment`, `RuntimeState`,
`EnforcementLevel`, `AllowedContext`, `PolicySnapshot` are all focus concepts.
`internal/statemachine` likewise moves under `mode/focus` (its states are the
focus lifecycle). Off-screen touches none of them.
## The Mode contract
```go
package mode
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
Kind() string // "focus", "offscreen"
Command(ctx context.Context, name string, payload json.RawMessage) error
View() any // JSON-marshalable projection
Active() bool // false ⇒ harness returns to idle
}
// Optional capabilities — the harness asserts for these.
type EvidenceConsumer interface { OnWindow(evidence.WindowSnapshot) } // focus only
type Expirer interface { Deadline() time.Time; Expire(context.Context) error } // focus only
```
`Command` returns an error for illegal/invalid commands; the web layer maps it
to HTTP (today's `IllegalTransitionError`→409 mapping moves with focus). `View`
returns the mode's own shape, wrapped by the harness in the envelope below.
## The Harness + Services
```go
type Harness struct {
mu sync.Mutex
active mode.Mode // nil = idle
factories map[string]func(Services) mode.Mode // "focus", "offscreen"
services Services
onChange []func()
}
func (h *Harness) Start(kind string) error // idle → mode (single-active invariant)
func (h *Harness) Command(ctx, name, payload) error // routes to active; cleans up active→done
func (h *Harness) State() mode.Envelope
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) // forwards iff active is EvidenceConsumer
func (h *Harness) Deadline() time.Time // delegates iff active is Expirer
func (h *Harness) Expire(ctx) error // delegates iff active is Expirer
```
After any `Command`/`Start`, if `active.Active()` is false the harness clears
`active` to nil (idle). `Start` on a non-idle harness is an error unless the
requested kind is already active.
`Services` is what a mode receives — the ports plus relocated infrastructure:
```go
type Services struct {
AI *ai.Service // coach/judge/nudge/reviewer + new proposer role
Tasks tasks.Provider // read (Today) + new write (Create)
Knowledge knowledge.Source
Enforce enforce.Guard
Clock func() time.Time
Store *store.Scope // mode-namespaced (~/.keel/modes/<kind>/)
Notify func() // fires harness change-listeners (SSE + status bar)
}
```
### The async primitive
`runFetchAsync` (today `session/roles.go:99`) moves to `harness/async.go` as a
helper parameterized by **the mode's own mutex** plus `Services.Notify`:
```go
type Async struct { mu *sync.Mutex; notify func() }
func (a Async) Run(timeout time.Duration, fetch func(context.Context), stale func() bool, apply func())
```
This preserves today's exact lock discipline: `fetch` runs with no lock held;
`stale`/`apply` run under the mode's mutex; `notify` fires once, after unlock.
Each mode constructs one `Async{mu: &m.mu, notify: svc.Notify}`. Generation
counters and status enums stay inside each mode, exactly as today.
## Focus mode (behavior-preserving extraction)
`session.Controller` minus the host plumbing becomes `focus.Mode`. The state
machine, drift/nudge/enforcement, evidence stats, reflection, and the planning
coach/tasks/knowledge fetches move verbatim. Focus implements `Mode`,
`EvidenceConsumer` (today's `RecordWindow`), and `Expirer` (today's
deadline/`Expire`). Its `View()` returns today's `session.State` shape,
unchanged, so the existing focus screens render identically.
Command mapping (today's routes → focus `Command` names): `planning`, `coach`,
`commitment`, `complete`, `end`, `refocus`, `ontask`.
`Locked` is removed: harness idle replaces it. `Planning→Active→Review→done`,
then the harness returns to idle. `admin_override` stays inside focus (an
enforcement-escape concern). **Existing focus tests move with the package and
must pass unchanged** — they are the proof the extraction preserved behavior.
## Off-screen mode (the proof)
The away-from-the-desk complement to focus. A one-shot request/response
lifecycle — this is what proves the abstraction holds for a mode that is *not*
a long-lived session.
```
start → assembling → proposed → (confirm | dismiss) → done
```
- **`Command "start"`** — collect + assemble + brain via the async primitive;
view status `pending`.
- **Collect (real frame elements):**
- `tasks.Provider.Today()` — life / non-work items.
- `~/owc/goals-2026.md` (Body/Spirit, People, Via Negativa) via the existing
single-file `knowledge.Source`.
- `~/owc` life-domain bugs via a small in-package glob read:
`physical-reset`, `workout-effort`, `nutrition-effort`,
`missing-social-life`, `make-environment-beautiful`, `marriage-strategy`.
- **Brain:** a new thin `ai.Proposer` role —
`Propose(ctx, brief) → Proposal{NextAction, Rationale}` — reusing
`ai.Backend`. A sibling to `Coach`, not a bend of it.
- **Surface:** phone-friendly proposal card (off-screen ⇒ you're not at the
desk; the phone surface earns its keep here).
- **`Command "confirm"` (gated effector):** a **new**
`tasks.Provider.Create(ctx, Task)` files the proposed task in Marvin,
preserving the `fieldUpdates` CRDT on write. The confirm click *is* the gate.
- **`Command "dismiss"`:** done, nothing written.
- **Memory:** none in v1 (AW buckets deferred).
Off-screen implements only `Mode` (no evidence stream, no timebox), so it
exercises the capability-assertion path: the harness forwards neither window
events nor an expiry timer to it.
## Surfacing
```go
package mode
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"` // active mode's View()
}
```
Web routes generalize:
- `POST /mode/{kind}/start``harness.Start`
- `POST /mode/command/{name}``harness.Command` (request body = payload)
- `GET /events` → SSE of the envelope (broadcaster mechanism unchanged)
- The server-owned expiry timer arms from `harness.Deadline()` and fires
`harness.Expire()` via the `Expirer` assertion (today's `web.armExpiry`
logic, retargeted).
`app.js` switches on `active_mode`: `""` → launcher (pick focus / off-screen);
`"focus"` → today's screens, fed by `.mode`; `"offscreen"` → the proposal card.
The status-file writer renders one line per active mode (focus's drift line,
off-screen's proposal summary, or `idle`). **This is the largest single chunk
of new work**: the focus UI is reshaped under the envelope and a small
off-screen view is added.
## Persistence (clean slate)
- Root `~/.keel/`; settings `~/.keel/settings.json`; status `~/.keel_status`.
- Per-mode namespace via `store.Scope`:
`~/.keel/modes/focus/{state.json, audit.jsonl, sessions/}`.
- Off-screen v1 persists nothing (ephemeral; restart = idle).
- **No migration** from `~/.antidrift/`.
## The rename (lands in this pass)
In-repo:
- `go.mod` module `antidrift``keel`; all import paths.
- `cmd/antidriftd``cmd/keeld`; binary `keeld`; log strings.
- `store`/`settings`/`statusfile` default paths → `~/.keel*`.
- `settings.SeedFromEnv` `ANTIDRIFT_*``KEEL_*`.
- `CLAUDE.md` naming-status section (rename now *done*).
- `keel-architecture.md`: §1/§8 (rename resolved), §5/§10 (remove the phantom
"house mode"; describe off-screen mode with the real collectors above).
External — Felix updates by hand (out of repo, listed in the plan):
- WM status-bar config reading `~/.antidrift_status``~/.keel_status`.
- Any systemd unit / shell alias / launcher invoking `antidriftd``keeld`.
## Testing
- **Focus:** existing tests relocate with the package (import paths only) and
must pass unchanged — the regression oracle for the extraction.
- **Harness:** new unit tests with a fake mode — single-active invariant,
command routing, capability assertion (window events only to
`EvidenceConsumer`s; expiry only to `Expirer`s), idle↔active transitions,
`Active()==false` cleanup to idle.
- **Off-screen:** new unit tests with fake `ai`/`tasks` — collect→propose then
`confirm` files exactly one task (CRDT-preserving); `dismiss` writes nothing;
the gate holds.
- **Web:** envelope routing test (idle / focus / off-screen shapes).
## Open question carried into the plan
Build order. Recommended: (1) rename in place (mechanical, keeps tests green),
(2) extract harness + `Mode` with focus as the only mode (tests green proves
the extraction), (3) add off-screen mode, (4) reshape the frontend under the
envelope. Each step leaves the daemon runnable.
+1 -1
View File
@@ -1,4 +1,4 @@
module antidrift module keel
go 1.26.3 go 1.26.3
+1 -1
View File
@@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string {
} }
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) { func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
f, err := os.CreateTemp("", "antidrift-codex-*.out") f, err := os.CreateTemp("", "keel-codex-*.out")
if err != nil { if err != nil {
return "", fmt.Errorf("codex: temp file: %w", err) return "", fmt.Errorf("codex: temp file: %w", err)
} }
+72
View File
@@ -0,0 +1,72 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// OffscreenProposal is the brain's suggestion for one worthwhile thing to do
// away from the screen. It is deliberately distinct from Proposal (the focus
// coach's commitment shape) to avoid a name collision.
type OffscreenProposal struct {
NextAction string `json:"next_action"`
Rationale string `json:"rationale"`
}
// Proposer turns an off-screen brief (goals, life-domain notes, today's tasks)
// into a single grounded off-screen action.
type Proposer interface {
Propose(ctx context.Context, brief string) (OffscreenProposal, error)
}
// Propose makes Service satisfy Proposer over the same backend as Coach.
func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) {
out, err := s.backend.Run(ctx, buildProposePrompt(brief))
if err != nil {
return OffscreenProposal{}, err
}
return parseOffscreenProposal(out)
}
func buildProposePrompt(brief string) string {
return `You are an off-screen advisor. Read the brief below — the user's goals, life-domain notes, and today's tasks — and pick ONE worthwhile thing for them to do now, away from the screen.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"next_action": "<one worthwhile off-screen action to do now>", "rationale": "<one short sentence: which goal or value it serves>"}
Rules:
- next_action: a single concrete off-screen action, doable now.
- rationale: one short sentence naming the goal or value it serves.
## Brief
` + brief
}
// parseOffscreenProposal extracts and validates an OffscreenProposal from raw
// CLI output, mirroring parseProposal's error discipline.
func parseOffscreenProposal(s string) (OffscreenProposal, error) {
if strings.TrimSpace(s) == "" {
return OffscreenProposal{}, ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return OffscreenProposal{}, ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
var raw OffscreenProposal
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
na := strings.TrimSpace(raw.NextAction)
if na == "" {
return OffscreenProposal{}, ErrInvalidProposal
}
return OffscreenProposal{
NextAction: na,
Rationale: strings.TrimSpace(raw.Rationale),
}, nil
}
+43
View File
@@ -0,0 +1,43 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestProposeParsesProposal(t *testing.T) {
svc := NewService(&fakeBackend{out: `{"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)
}
}
func TestProposeBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Propose(context.Background(), "brief"); err == nil {
t.Fatal("want backend error")
}
}
func TestProposeUnparseable(t *testing.T) {
fb := &fakeBackend{out: "I cannot help."}
if _, err := NewService(fb).Propose(context.Background(), "brief"); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err)
}
}
func TestProposePromptIncludesBrief(t *testing.T) {
fb := &fakeBackend{out: `{"next_action":"a","rationale":"b"}`}
if _, err := NewService(fb).Propose(context.Background(), "water the tomato plants"); err != nil {
t.Fatalf("Propose: %v", err)
}
if !strings.Contains(fb.gotPrompt, "water the tomato plants") {
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package enforce
import ( import (
"context" "context"
"antidrift/internal/winapi" "keel/internal/winapi"
) )
// NewGuard returns the Windows window-minimize guard. // NewGuard returns the Windows window-minimize guard.
+1 -1
View File
@@ -3,7 +3,7 @@ package evidence
import ( import (
"strings" "strings"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
// MatchesAllowed reports whether a window (class/title) is on-task per ctx. // MatchesAllowed reports whether a window (class/title) is on-task per ctx.
+1 -1
View File
@@ -3,7 +3,7 @@ package evidence
import ( import (
"testing" "testing"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
func TestWindowClassMatchesCaseAndTrim(t *testing.T) { func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"context" "context"
"time" "time"
"antidrift/internal/winapi" "keel/internal/winapi"
) )
// pollInterval is how often the Windows sensor samples the foreground window. // pollInterval is how often the Windows sensor samples the foreground window.
-1
View File
@@ -86,4 +86,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
Health: EvidenceHealth{Available: true}, Health: EvidenceHealth{Available: true},
} }
} }
+44
View File
@@ -0,0 +1,44 @@
// 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()
}
}()
}
+48
View File
@@ -0,0 +1,48 @@
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
}
+187
View File
@@ -0,0 +1,187 @@
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()
}
// Services returns the shared services the harness hands to factories, with
// Notify wired to the harness change fan-out. Use it to build a mode for Adopt
// (e.g. startup restoration) so the restored mode's async work reaches the
// harness's onChange listeners.
func (h *Harness) Services() Services {
h.mu.Lock()
defer h.mu.Unlock()
return h.services
}
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
}
+156
View File
@@ -0,0 +1,156 @@
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)
}
}
func TestServicesNotifyReachesOnChangeListeners(t *testing.T) {
h := New(Services{}, map[string]Factory{})
fired := make(chan struct{}, 1)
h.AddOnChange(func() { fired <- struct{}{} })
svc := h.Services()
if svc.Notify == nil {
t.Fatal("Services().Notify is nil; restore path would not propagate updates")
}
svc.Notify() // simulate a restored mode's async completion firing notify
select {
case <-fired:
case <-time.After(time.Second):
t.Fatal("Services().Notify did not reach the registered onChange listener")
}
}
+24
View File
@@ -0,0 +1,24 @@
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()
}
+3 -3
View File
@@ -22,7 +22,7 @@ type FileSource struct {
} }
// NewFileSource builds the adapter. defaultPath is used when Load receives an // NewFileSource builds the adapter. defaultPath is used when Load receives an
// empty path; if it too is empty, Load falls back to ~/.antidrift/knowledge.md. // empty path; if it too is empty, Load falls back to ~/.keel/knowledge.md.
func NewFileSource(defaultPath string) *FileSource { func NewFileSource(defaultPath string) *FileSource {
return &FileSource{defaultPath: defaultPath} return &FileSource{defaultPath: defaultPath}
} }
@@ -46,7 +46,7 @@ func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) {
return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil
} }
// resolve picks path, else the default, else ~/.antidrift/knowledge.md; expands // resolve picks path, else the default, else ~/.keel/knowledge.md; expands
// a leading ~; and makes the result absolute for stable display. // a leading ~; and makes the result absolute for stable display.
func (s *FileSource) resolve(path string) string { func (s *FileSource) resolve(path string) string {
p := path p := path
@@ -55,7 +55,7 @@ func (s *FileSource) resolve(path string) string {
} }
if p == "" { if p == "" {
if home, err := os.UserHomeDir(); err == nil { if home, err := os.UserHomeDir(); err == nil {
p = filepath.Join(home, ".antidrift", "knowledge.md") p = filepath.Join(home, ".keel", "knowledge.md")
} }
} }
p = expandTilde(p) p = expandTilde(p)
@@ -1,12 +1,12 @@
package session package focus
import ( import (
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/store"
"context" "context"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"log" "log"
"strings" "strings"
"time" "time"
@@ -31,7 +31,7 @@ const (
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching // SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
// working; unmatched windows simply stay idle. // working; unmatched windows simply stay idle.
func (c *Controller) SetDriftJudge(j ai.DriftJudge) { func (c *Mode) SetDriftJudge(j ai.DriftJudge) {
c.mu.Lock() c.mu.Lock()
c.judge = j c.judge = j
c.mu.Unlock() c.mu.Unlock()
@@ -39,7 +39,7 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize // SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
// enforcement; everything else behaves identically. // enforcement; everything else behaves identically.
func (c *Controller) SetGuard(g enforce.Guard) { func (c *Mode) SetGuard(g enforce.Guard) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.guard = g c.guard = g
@@ -47,7 +47,7 @@ func (c *Controller) SetGuard(g enforce.Guard) {
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging; // SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected. // local matching and the drift judge are unaffected.
func (c *Controller) SetNudge(n ai.Nudger) { func (c *Mode) SetNudge(n ai.Nudger) {
c.mu.Lock() c.mu.Lock()
c.nudge = n c.nudge = n
c.mu.Unlock() c.mu.Unlock()
@@ -55,7 +55,7 @@ func (c *Controller) SetNudge(n ai.Nudger) {
// commitmentLineLocked renders the active commitment as a single line for AI // commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu. // prompts, or "" if there is none. Caller holds mu.
func (c *Controller) commitmentLineLocked() string { func (c *Mode) commitmentLineLocked() string {
if c.commitment == nil { if c.commitment == nil {
return "" return ""
} }
@@ -63,14 +63,14 @@ func (c *Controller) commitmentLineLocked() string {
} }
// recentTitlesForTest returns a copy of the recent-titles ring (test-only). // recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Controller) recentTitlesForTest() []string { func (c *Mode) recentTitlesForTest() []string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return append([]string(nil), c.recentTitles...) return append([]string(nil), c.recentTitles...)
} }
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu. // resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Controller) resetDriftLocked() { func (c *Mode) resetDriftLocked() {
c.driftStatus = driftIdle c.driftStatus = driftIdle
c.driftReason = "" c.driftReason = ""
c.driftGen++ c.driftGen++
@@ -85,7 +85,7 @@ func (c *Controller) resetDriftLocked() {
// OnTask appends the current window class to the session allowed-context, clears // OnTask appends the current window class to the session allowed-context, clears
// drift, drops the cached verdict for the current window, and persists. The // drift, drops the cached verdict for the current window, and persists. The
// class now matches locally and is never re-judged. // class now matches locally and is never re-judged.
func (c *Controller) OnTask() error { func (c *Mode) OnTask() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive { if c.runtimeState != domain.RuntimeActive {
@@ -109,7 +109,7 @@ func (c *Controller) OnTask() error {
// Refocus clears the current drift verdict without changing allowed-context, and // Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current window so it may be judged again later. // drops the cached verdict for the current window so it may be judged again later.
func (c *Controller) Refocus() error { func (c *Mode) Refocus() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive { if c.runtimeState != domain.RuntimeActive {
@@ -126,7 +126,7 @@ func (c *Controller) Refocus() error {
// RecordWindow ingests one sensor observation. It accumulates time only while // RecordWindow ingests one sensor observation. It accumulates time only while
// Active, always tracks the latest window for display, and fires onChange after // Active, always tracks the latest window for display, and fires onChange after
// releasing the mutex. // releasing the mutex.
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) { func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) {
c.mu.Lock() c.mu.Lock()
c.latestWindow = snap c.latestWindow = snap
if c.runtimeState != domain.RuntimeActive || c.stats == nil { if c.runtimeState != domain.RuntimeActive || c.stats == nil {
@@ -154,7 +154,7 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
// enforced — guard wired, level is block, and drift is confirmed — else nil. The // enforced — guard wired, level is block, and drift is confirmed — else nil. The
// returned func performs blocking X11 I/O and MUST run after the caller releases // returned func performs blocking X11 I/O and MUST run after the caller releases
// c.mu, following the off-lock async-I/O discipline used by the other roles. // c.mu, following the off-lock async-I/O discipline used by the other roles.
func (c *Controller) enforceActionLocked() func() { func (c *Mode) enforceActionLocked() func() {
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting { if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
return nil return nil
} }
@@ -163,7 +163,7 @@ func (c *Controller) enforceActionLocked() func() {
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout) ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
defer cancel() defer cancel()
if err := guard.MinimizeActive(ctx); err != nil { if err := guard.MinimizeActive(ctx); err != nil {
log.Printf("session: enforce minimize failed: %v", err) log.Printf("focus: enforce minimize failed: %v", err)
} }
} }
} }
@@ -173,7 +173,7 @@ func (c *Controller) enforceActionLocked() func() {
// returns the judging closure; the caller runs it in a goroutine after // returns the judging closure; the caller runs it in a goroutine after
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has // releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
// already verified the runtime is Active. // already verified the runtime is Active.
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() { func (c *Mode) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path // 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
@@ -241,7 +241,7 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.driftReason = prevReason c.driftReason = prevReason
} }
c.mu.Unlock() c.mu.Unlock()
log.Printf("session: drift judge failed: %v", err) log.Printf("focus: drift judge failed: %v", err)
c.notify() c.notify()
return return
} }
@@ -268,7 +268,7 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
// episode or a session change — is discarded rather than surfacing stale, never // episode or a session change — is discarded rather than surfacing stale, never
// fabricates a concern on error, and notifies only after unlocking. Caller holds // fabricates a concern on error, and notifies only after unlocking. Caller holds
// mu and has already verified the runtime is Active. // mu and has already verified the runtime is Active.
func (c *Controller) maybeNudgeLocked(now time.Time) func() { func (c *Mode) maybeNudgeLocked(now time.Time) func() {
if c.nudge == nil || len(c.recentTitles) < 2 { if c.nudge == nil || len(c.recentTitles) < 2 {
return nil return nil
} }
@@ -293,7 +293,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
} }
if err != nil { if err != nil {
c.mu.Unlock() c.mu.Unlock()
log.Printf("session: nudge failed: %v", err) log.Printf("focus: nudge failed: %v", err)
return // never fabricate a concern; leave any prior message intact return // never fabricate a concern; leave any prior message intact
} }
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
@@ -304,7 +304,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
// recordTitleLocked appends a non-empty title to the recent ring, skipping a // recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu. // consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Controller) recordTitleLocked(title string) { func (c *Mode) recordTitleLocked(title string) {
t := strings.TrimSpace(title) t := strings.TrimSpace(title)
if t == "" { if t == "" {
return return
@@ -328,7 +328,7 @@ func verdictKey(class, title string) string {
} }
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu. // applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) { func (c *Mode) applyVerdictLocked(v ai.Verdict) {
if v.OnTask { if v.OnTask {
c.driftStatus = driftOnTask c.driftStatus = driftOnTask
c.driftReason = "" c.driftReason = ""
+50
View File
@@ -0,0 +1,50 @@
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
}
@@ -1,8 +1,8 @@
// Package session owns the daemon's in-memory state of truth and persists a // Package focus owns the daemon's in-memory state of truth and persists a
// snapshot on every change. Transitions go through the pure statemachine. It // snapshot on every change. Transitions go through the pure statemachine. It
// also owns per-session evidence stats: it accumulates active-window time while // also owns per-session evidence stats: it accumulates active-window time while
// Active, logs raw focus events, and seals each session into the audit chain. // Active, logs raw focus events, and seals each session into the audit chain.
package session package focus
import ( import (
"errors" "errors"
@@ -12,14 +12,15 @@ import (
"sync" "sync"
"time" "time"
"antidrift/internal/ai" "keel/internal/ai"
"antidrift/internal/domain" "keel/internal/enforce"
"antidrift/internal/enforce" "keel/internal/evidence"
"antidrift/internal/evidence" "keel/internal/harness"
"antidrift/internal/knowledge" "keel/internal/knowledge"
"antidrift/internal/statemachine" "keel/internal/mode/focus/domain"
"antidrift/internal/store" "keel/internal/mode/focus/statemachine"
"antidrift/internal/tasks" "keel/internal/store"
"keel/internal/tasks"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -29,13 +30,14 @@ const (
sessionRetention = 30 * 24 * time.Hour sessionRetention = 30 * 24 * time.Hour
) )
var ErrNotPlanning = errors.New("session: coaching is only available while planning") var ErrNotPlanning = errors.New("focus: coaching is only available while planning")
var ErrNotActive = errors.New("session: only available while a commitment is active") var ErrNotActive = errors.New("focus: only available while a commitment is active")
// Controller holds runtime state and the active commitment behind a mutex. // Mode holds runtime state and the active commitment behind a mutex.
type Controller struct { type Mode struct {
mu sync.Mutex mu sync.Mutex
async harness.Async
runtimeState domain.RuntimeState runtimeState domain.RuntimeState
commitment *domain.Commitment commitment *domain.Commitment
deadline time.Time deadline time.Time
@@ -90,13 +92,13 @@ type Controller struct {
// New loads any persisted snapshot, prunes stale session logs, and rebuilds // New loads any persisted snapshot, prunes stale session logs, and rebuilds
// in-memory stats from the raw log if a live session was interrupted. // in-memory stats from the raw log if a live session was interrupted.
func New(snapshotPath string) (*Controller, error) { func New(snapshotPath string) (*Mode, error) {
s, err := store.Load(snapshotPath) s, err := store.Load(snapshotPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dir := filepath.Dir(snapshotPath) dir := filepath.Dir(snapshotPath)
c := &Controller{ c := &Mode{
runtimeState: s.RuntimeState, runtimeState: s.RuntimeState,
commitment: s.Commitment, commitment: s.Commitment,
snapshotPath: snapshotPath, snapshotPath: snapshotPath,
@@ -114,6 +116,7 @@ func New(snapshotPath string) (*Controller, error) {
if c.runtimeState == "" { if c.runtimeState == "" {
c.runtimeState = domain.RuntimeLocked c.runtimeState = domain.RuntimeLocked
} }
c.async = harness.NewAsync(&c.mu, c.notify)
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
c.allowedClasses = s.AllowedWindowClasses c.allowedClasses = s.AllowedWindowClasses
@@ -129,7 +132,7 @@ func New(snapshotPath string) (*Controller, error) {
// SetClock overrides the time source (tests only). Call before starting a // SetClock overrides the time source (tests only). Call before starting a
// session. // session.
func (c *Controller) SetClock(f func() time.Time) { func (c *Mode) SetClock(f func() time.Time) {
c.mu.Lock() c.mu.Lock()
c.clock = f c.clock = f
c.mu.Unlock() c.mu.Unlock()
@@ -138,7 +141,7 @@ func (c *Controller) SetClock(f func() time.Time) {
// SetOnChange registers the sole change listener, replacing any already set. A // SetOnChange registers the sole change listener, replacing any already set. A
// listener is fired after a state change (focus updates, role results) with the // listener is fired after a state change (focus updates, role results) with the
// mutex released. Use AddOnChange to register additional listeners. // mutex released. Use AddOnChange to register additional listeners.
func (c *Controller) SetOnChange(f func()) { func (c *Mode) SetOnChange(f func()) {
c.mu.Lock() c.mu.Lock()
c.onChanges = []func(){f} c.onChanges = []func(){f}
c.mu.Unlock() c.mu.Unlock()
@@ -147,13 +150,13 @@ func (c *Controller) SetOnChange(f func()) {
// AddOnChange registers an additional change listener alongside any already set, // AddOnChange registers an additional change listener alongside any already set,
// so several consumers (the web broadcaster, the status-file writer) all react // so several consumers (the web broadcaster, the status-file writer) all react
// to the same notification rather than only the last one wired. // to the same notification rather than only the last one wired.
func (c *Controller) AddOnChange(f func()) { func (c *Mode) AddOnChange(f func()) {
c.mu.Lock() c.mu.Lock()
c.onChanges = append(c.onChanges, f) c.onChanges = append(c.onChanges, f)
c.mu.Unlock() c.mu.Unlock()
} }
func (c *Controller) notify() { func (c *Mode) notify() {
c.mu.Lock() c.mu.Lock()
fs := append([]func(){}, c.onChanges...) fs := append([]func(){}, c.onChanges...)
c.mu.Unlock() c.mu.Unlock()
@@ -165,20 +168,20 @@ func (c *Controller) notify() {
} }
// State returns the current broadcastable state. Safe for concurrent use. // State returns the current broadcastable state. Safe for concurrent use.
func (c *Controller) State() State { func (c *Mode) State() State {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.stateLocked() return c.stateLocked()
} }
// Deadline returns the active commitment deadline, or the zero time. // Deadline returns the active commitment deadline, or the zero time.
func (c *Controller) Deadline() time.Time { func (c *Mode) Deadline() time.Time {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.deadline return c.deadline
} }
func (c *Controller) persistLocked() error { func (c *Mode) persistLocked() error {
snap := store.Snapshot{ snap := store.Snapshot{
RuntimeState: c.runtimeState, RuntimeState: c.runtimeState,
Commitment: c.commitment, Commitment: c.commitment,
@@ -199,7 +202,7 @@ func (c *Controller) persistLocked() error {
} }
// EnterPlanning moves Locked -> Planning. // EnterPlanning moves Locked -> Planning.
func (c *Controller) EnterPlanning() error { func (c *Mode) EnterPlanning() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning) next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
@@ -214,7 +217,7 @@ func (c *Controller) EnterPlanning() error {
} }
// AllowedClassesForTest exposes the session allowed classes for tests. // AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string { func (c *Mode) AllowedClassesForTest() []string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return append([]string(nil), c.allowedClasses...) return append([]string(nil), c.allowedClasses...)
@@ -222,7 +225,7 @@ func (c *Controller) AllowedClassesForTest() []string {
// EnforcementLevelForTest exposes the active session's enforcement level. Tests // EnforcementLevelForTest exposes the active session's enforcement level. Tests
// only. // only.
func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel { func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.enforcementLevel return c.enforcementLevel
@@ -231,7 +234,7 @@ func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
// StartManualCommitment validates input, activates a new commitment, mints a // StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning -> // session, seeds evidence stats from the latest window, and moves Planning ->
// Active. // Active.
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error { func (c *Mode) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
commitment, err := domain.NewManual(nextAction, successCondition, timebox) commitment, err := domain.NewManual(nextAction, successCondition, timebox)
@@ -271,12 +274,12 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
} }
// Complete moves Active -> Review with a "completed" outcome. // Complete moves Active -> Review with a "completed" outcome.
func (c *Controller) Complete() error { return c.enterReview("completed") } func (c *Mode) Complete() error { return c.enterReview("completed") }
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed). // Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
func (c *Controller) Expire() error { return c.enterReview("expired") } func (c *Mode) Expire() error { return c.enterReview("expired") }
func (c *Controller) enterReview(outcome string) error { func (c *Mode) enterReview(outcome string) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
@@ -303,7 +306,7 @@ func (c *Controller) enterReview(outcome string) error {
// End moves Review -> Locked, writes the session summary to the audit chain, // End moves Review -> Locked, writes the session summary to the audit chain,
// and clears the commitment and stats. // and clears the commitment and stats.
func (c *Controller) End() error { func (c *Mode) End() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod) next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
@@ -314,7 +317,7 @@ func (c *Controller) End() error {
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil { if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
// State integrity over audit completeness: the transition still // State integrity over audit completeness: the transition still
// completes. Surfaced for the operator; no auto-retry in M1. // completes. Surfaced for the operator; no auto-retry in M1.
log.Printf("session: audit append failed: %v", err) log.Printf("focus: audit append failed: %v", err)
} }
} }
c.runtimeState = next c.runtimeState = next
@@ -328,7 +331,7 @@ func (c *Controller) End() error {
return c.persistLocked() return c.persistLocked()
} }
func (c *Controller) buildSummaryLocked() store.SessionSummary { func (c *Mode) buildSummaryLocked() store.SessionSummary {
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets)) buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
for k, d := range c.stats.Buckets { for k, d := range c.stats.Buckets {
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
@@ -1,4 +1,4 @@
package session package focus
import ( import (
"context" "context"
@@ -11,15 +11,15 @@ import (
"testing" "testing"
"time" "time"
"antidrift/internal/ai" "keel/internal/ai"
"antidrift/internal/domain" "keel/internal/evidence"
"antidrift/internal/evidence" "keel/internal/knowledge"
"antidrift/internal/knowledge" "keel/internal/mode/focus/domain"
"antidrift/internal/store" "keel/internal/store"
"antidrift/internal/tasks" "keel/internal/tasks"
) )
func newTestController(t *testing.T) (*Controller, string) { func newTestController(t *testing.T) (*Mode, string) {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
c, err := New(path) c, err := New(path)
@@ -271,7 +271,7 @@ func (f *fakeCoach) grounding() string {
} }
// waitCoachStatus polls until the coach view reaches want, or fails after 2s. // waitCoachStatus polls until the coach view reaches want, or fails after 2s.
func waitCoachStatus(t *testing.T, c *Controller, want string) State { func waitCoachStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -442,7 +442,7 @@ func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title str
return f.verdict, f.err return f.verdict, f.err
} }
func waitDriftStatus(t *testing.T, c *Controller, want string) State { func waitDriftStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -456,12 +456,12 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State {
return State{} return State{}
} }
func startActive(t *testing.T, c *Controller, allowed []string) { func startActive(t *testing.T, c *Mode, allowed []string) {
t.Helper() t.Helper()
startActiveLevel(t, c, allowed, domain.EnforcementWarn) startActiveLevel(t, c, allowed, domain.EnforcementWarn)
} }
func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) { func startActiveLevel(t *testing.T, c *Mode, allowed []string, level domain.EnforcementLevel) {
t.Helper() t.Helper()
if err := c.EnterPlanning(); err != nil { if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err) t.Fatalf("planning: %v", err)
@@ -752,7 +752,7 @@ func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, er
return f(ctx, c, t) return f(ctx, c, t)
} }
func waitNudge(t *testing.T, c *Controller, want string) { func waitNudge(t *testing.T, c *Mode, want string) {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -963,8 +963,10 @@ func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return f.list, f.err return f.list, f.err
} }
func (f *fakeProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s. // waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
func waitTasksStatus(t *testing.T, c *Controller, want string) State { func waitTasksStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -1186,7 +1188,7 @@ func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile,
} }
// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s. // waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s.
func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State { func waitKnowledgeStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -1289,7 +1291,7 @@ func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai
} }
// waitReflectionStatus polls until the reflection view reaches want, or fails after 2s. // waitReflectionStatus polls until the reflection view reaches want, or fails after 2s.
func waitReflectionStatus(t *testing.T, c *Controller, want string) State { func waitReflectionStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -1303,7 +1305,7 @@ func waitReflectionStatus(t *testing.T, c *Controller, want string) State {
return State{} return State{}
} }
func driveToReview(t *testing.T, c *Controller) { func driveToReview(t *testing.T, c *Mode) {
t.Helper() t.Helper()
if err := c.EnterPlanning(); err != nil { if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err) t.Fatalf("planning: %v", err)
+82
View File
@@ -0,0 +1,82 @@
package focus
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"keel/internal/evidence"
"keel/internal/mode"
"keel/internal/mode/focus/domain"
)
// Compile-time assertions: focus.Mode must satisfy the harness contracts.
var _ mode.Mode = (*Mode)(nil)
var _ mode.EvidenceConsumer = (*Mode)(nil)
var _ mode.Expirer = (*Mode)(nil)
// Kind identifies this mode to the harness.
func (c *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 (c *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
switch name {
case "planning":
return c.EnterPlanning()
case "coach":
var r struct {
Intent string `json:"intent"`
}
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
return c.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 c.StartManualCommitment(r.NextAction, r.SuccessCondition,
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
case "complete":
return c.Complete()
case "end":
return c.End()
case "refocus":
return c.Refocus()
case "ontask":
return c.OnTask()
default:
return fmt.Errorf("focus: unknown command %q", name)
}
}
// View returns the focus state projection (the existing State shape).
func (c *Mode) View() any { return c.State() }
// Active is true from Planning through Review. End() drives the runtime back to
// Locked, which the harness reads as "done" and releases.
func (c *Mode) Active() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.runtimeState != domain.RuntimeLocked
}
// OnWindow forwards the sensor stream to the existing evidence recorder.
func (c *Mode) OnWindow(w evidence.WindowSnapshot) { c.RecordWindow(w) }
@@ -1,14 +1,14 @@
package session package focus
import ( import (
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/knowledge"
"antidrift/internal/store"
"antidrift/internal/tasks"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"keel/internal/ai"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"keel/internal/tasks"
"strings" "strings"
"time" "time"
) )
@@ -58,7 +58,7 @@ const (
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes // SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully. // RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach) { func (c *Mode) SetCoach(coach ai.Coach) {
c.mu.Lock() c.mu.Lock()
c.coach = coach c.coach = coach
c.mu.Unlock() c.mu.Unlock()
@@ -66,7 +66,7 @@ func (c *Controller) SetCoach(coach ai.Coach) {
// resetCoachLocked returns coach state to idle and invalidates any in-flight // resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu. // request. Caller holds mu.
func (c *Controller) resetCoachLocked() { func (c *Mode) resetCoachLocked() {
c.coachStatus = coachIdle c.coachStatus = coachIdle
c.coachProposal = nil c.coachProposal = nil
c.coachErr = "" c.coachErr = ""
@@ -75,47 +75,16 @@ func (c *Controller) resetCoachLocked() {
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps // SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent. // the planning tasks list absent.
func (c *Controller) SetTasks(p tasks.Provider) { func (c *Mode) SetTasks(p tasks.Provider) {
c.mu.Lock() c.mu.Lock()
c.tasksProvider = p c.tasksProvider = p
c.mu.Unlock() c.mu.Unlock()
} }
// runFetchAsync launches a generation-guarded background fetch. The caller has
// already captured its dependencies and (for the *Locked callers) holds c.mu;
// this method only spawns the goroutine, which re-acquires the lock itself.
//
// Closure contract:
// - fetch performs the I/O and runs with NO lock held; it must not touch c
// fields without taking c.mu itself.
// - stale runs under the re-acquired c.mu and returns true to DISCARD the
// result (e.g. a newer generation superseded this one).
// - apply runs under the re-acquired c.mu and records the result (persisting
// itself when the role requires it); it must NOT call c.notify — the helper
// owns the post-unlock notify, and notify self-locks, so calling it under
// c.mu would deadlock.
//
// On a non-stale completion the helper notifies after releasing the lock.
func (c *Controller) runFetchAsync(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)
c.mu.Lock()
if stale() {
c.mu.Unlock()
return
}
apply()
c.mu.Unlock()
c.notify()
}()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider // startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or // is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu. // post-planning results, and notifies on completion. Caller holds mu.
func (c *Controller) startTasksFetchLocked() { func (c *Mode) startTasksFetchLocked() {
c.tasksList = nil c.tasksList = nil
if c.tasksProvider == nil { if c.tasksProvider == nil {
c.tasksStatus = tasksIdle c.tasksStatus = tasksIdle
@@ -127,7 +96,7 @@ func (c *Controller) startTasksFetchLocked() {
provider := c.tasksProvider provider := c.tasksProvider
var list []tasks.Task var list []tasks.Task
var err error var err error
c.runFetchAsync(tasksTimeout, c.async.Run(tasksTimeout,
func(ctx context.Context) { list, err = provider.Today(ctx) }, func(ctx context.Context) { list, err = provider.Today(ctx) },
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning }, func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
func() { func() {
@@ -143,7 +112,7 @@ func (c *Controller) startTasksFetchLocked() {
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source // SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded. // keeps the planning knowledge view absent and the coach ungrounded.
func (c *Controller) SetKnowledge(s knowledge.Source) { func (c *Mode) SetKnowledge(s knowledge.Source) {
c.mu.Lock() c.mu.Lock()
c.knowledgeSrc = s c.knowledgeSrc = s
c.mu.Unlock() c.mu.Unlock()
@@ -152,7 +121,7 @@ func (c *Controller) SetKnowledge(s knowledge.Source) {
// SetKnowledgePath selects an explicit profile path (session-only; not // SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the // persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default. // cached grounding update. An empty path resets to the adapter default.
func (c *Controller) SetKnowledgePath(path string) { func (c *Mode) SetKnowledgePath(path string) {
c.mu.Lock() c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path) c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning planning := c.runtimeState == domain.RuntimePlanning
@@ -169,7 +138,7 @@ func (c *Controller) SetKnowledgePath(path string) {
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or // Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached // post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu. // in knowledgeText for the coach to read. Caller holds mu.
func (c *Controller) startKnowledgeFetchLocked() { func (c *Mode) startKnowledgeFetchLocked() {
c.knowledgeText = "" c.knowledgeText = ""
c.knowledgeChars = 0 c.knowledgeChars = 0
if c.knowledgeSrc == nil { if c.knowledgeSrc == nil {
@@ -183,7 +152,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
path := c.knowledgePath path := c.knowledgePath
var prof knowledge.Profile var prof knowledge.Profile
var err error var err error
c.runFetchAsync(knowledgeTimeout, c.async.Run(knowledgeTimeout,
func(ctx context.Context) { prof, err = src.Load(ctx, path) }, func(ctx context.Context) { prof, err = src.Load(ctx, path) },
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning }, func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
func() { func() {
@@ -210,7 +179,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and // SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward. // leaves the coach ungrounded by any carry-forward.
func (c *Controller) SetReviewer(r ai.Reviewer) { func (c *Mode) SetReviewer(r ai.Reviewer) {
c.mu.Lock() c.mu.Lock()
c.reviewer = r c.reviewer = r
c.mu.Unlock() c.mu.Unlock()
@@ -223,7 +192,7 @@ func (c *Controller) SetReviewer(r ai.Reviewer) {
// review (a later session's fetch) bumps the generation and discards this one. // review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer // The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu. // never leaves stale data from the previous session. Caller holds mu.
func (c *Controller) startReflectionFetchLocked() { func (c *Mode) startReflectionFetchLocked() {
c.reflectionRecap = "" c.reflectionRecap = ""
c.carryForward = "" c.carryForward = ""
if c.reviewer == nil { if c.reviewer == nil {
@@ -245,7 +214,7 @@ func (c *Controller) startReflectionFetchLocked() {
history := buildReflectionHistory(c.auditPath) history := buildReflectionHistory(c.auditPath)
var refl ai.Reflection var refl ai.Reflection
var err error var err error
c.runFetchAsync(reflectionTimeout, c.async.Run(reflectionTimeout,
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) }, func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
func() bool { return gen != c.reflectionGen }, func() bool { return gen != c.reflectionGen },
func() { func() {
@@ -267,7 +236,7 @@ func (c *Controller) startReflectionFetchLocked() {
// totals, and a top-N on-task list and top-N off-task list. The split fields are // totals, and a top-N on-task list and top-N off-task list. The split fields are
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still // populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
// set (End clears them, but enterReview runs before End). // set (End clears them, but enterReview runs before End).
func (c *Controller) buildReflectionFinishedLocked() string { func (c *Mode) buildReflectionFinishedLocked() string {
var na, sc string var na, sc string
if c.commitment != nil { if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
@@ -340,7 +309,7 @@ func buildReflectionHistory(auditPath string) string {
// composedGroundingLocked combines the standing profile (knowledge port) with // composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string // the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu. // the coach already accepts. Caller holds mu.
func (c *Controller) composedGroundingLocked() string { func (c *Mode) composedGroundingLocked() string {
g := c.knowledgeText g := c.knowledgeText
if c.carryForward != "" { if c.carryForward != "" {
if g != "" { if g != "" {
@@ -354,7 +323,7 @@ func (c *Controller) composedGroundingLocked() string {
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if // RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach // not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted. // state). The proposal is ephemeral and never persisted.
func (c *Controller) RequestCoach(intent string) error { func (c *Mode) RequestCoach(intent string) error {
c.mu.Lock() c.mu.Lock()
if c.runtimeState != domain.RuntimePlanning { if c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock() c.mu.Unlock()
@@ -380,7 +349,7 @@ func (c *Controller) RequestCoach(intent string) error {
var prop ai.Proposal var prop ai.Proposal
var err error var err error
c.runFetchAsync(coachTimeout, c.async.Run(coachTimeout,
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) }, func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning }, func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
func() { func() {
@@ -5,7 +5,7 @@ package statemachine
import ( import (
"fmt" "fmt"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance // RuntimeAction enumerates runtime transitions. Activate's policy acceptance
@@ -3,7 +3,7 @@ package statemachine
import ( import (
"testing" "testing"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) { func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
@@ -1,8 +1,8 @@
package session package focus
import ( import (
"antidrift/internal/evidence" "keel/internal/evidence"
"antidrift/internal/store" "keel/internal/store"
"time" "time"
) )
@@ -27,7 +27,7 @@ type EvidenceStats struct {
// applyEvent advances stats by one observation: it credits the prior segment to // applyEvent advances stats by one observation: it credits the prior segment to
// the prior bucket, counts a context switch on key change, and records the new // the prior bucket, counts a context switch on key change, and records the new
// current window. Used by both live tracking and crash replay. Caller holds mu. // current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) { func (c *Mode) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast { if c.stats.hasLast {
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt)) c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
} }
@@ -47,7 +47,7 @@ func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs // runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
// before the state transition). idle/pending route to unclassified: honest, never // before the state transition). idle/pending route to unclassified: honest, never
// falsely on-task. Caller holds mu. // falsely on-task. Caller holds mu.
func (c *Controller) creditLocked(k bucketKey, d time.Duration) { func (c *Mode) creditLocked(k bucketKey, d time.Duration) {
c.stats.Buckets[k] += d c.stats.Buckets[k] += d
switch c.driftStatus { switch c.driftStatus {
case driftOnTask: case driftOnTask:
@@ -60,7 +60,7 @@ func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
} }
// replayStats rebuilds in-memory stats from the raw session log after a crash. // replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Controller) replayStats(sessionID string) { func (c *Mode) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID) events, err := store.ReplaySession(c.sessionsDir, sessionID)
if err != nil || len(events) == 0 { if err != nil || len(events) == 0 {
c.stats = &EvidenceStats{ c.stats = &EvidenceStats{
@@ -1,7 +1,7 @@
package session package focus
import ( import (
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
"sort" "sort"
"time" "time"
) )
@@ -102,7 +102,7 @@ type State struct {
Drift *DriftView `json:"drift,omitempty"` Drift *DriftView `json:"drift,omitempty"`
} }
func (c *Controller) stateLocked() State { func (c *Mode) stateLocked() State {
st := State{RuntimeState: c.runtimeState} st := State{RuntimeState: c.runtimeState}
if c.commitment != nil { if c.commitment != nil {
view := &CommitmentView{ view := &CommitmentView{
+44
View File
@@ -0,0 +1,44 @@
// 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"`
}
+131
View File
@@ -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)"
}
+158
View File
@@ -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
}
+140
View File
@@ -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")
}
}
+8 -8
View File
@@ -1,5 +1,5 @@
// Package settings persists the daemon's user-editable configuration as a single // Package settings persists the daemon's user-editable configuration as a single
// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a // JSON file (~/.keel/settings.json), parallel to the store snapshot. It is a
// leaf type package: it imports nothing else in the app so any layer may depend // leaf type package: it imports nothing else in the app so any layer may depend
// on the Settings value without pulling in adapters. // on the Settings value without pulling in adapters.
package settings package settings
@@ -17,20 +17,20 @@ import (
var ErrInvalidBackend = errors.New("settings: invalid ai backend") var ErrInvalidBackend = errors.New("settings: invalid ai backend")
// Settings is the user-editable configuration. Field names mirror the env vars // Settings is the user-editable configuration. Field names mirror the env vars
// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE. // they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE.
type Settings struct { type Settings struct {
AIBackend string `json:"ai_backend"` AIBackend string `json:"ai_backend"`
MarvinCmd string `json:"marvin_cmd"` MarvinCmd string `json:"marvin_cmd"`
KnowledgePath string `json:"knowledge_path"` KnowledgePath string `json:"knowledge_path"`
} }
// DefaultPath returns ~/.antidrift/settings.json. // DefaultPath returns ~/.keel/settings.json.
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, ".antidrift", "settings.json"), nil return filepath.Join(home, ".keel", "settings.json"), nil
} }
// Load reads a settings file. A missing file is an error; callers treat that as // Load reads a settings file. A missing file is an error; callers treat that as
@@ -64,17 +64,17 @@ func Save(path string, s Settings) error {
return os.Rename(tmp, path) return os.Rename(tmp, path)
} }
// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying // SeedFromEnv builds a Settings from the KEEL_* env vars, applying
// the same defaults the daemon used before the settings file existed. An unset // the same defaults the daemon used before the settings file existed. An unset
// AI backend defaults to "claude" (matching ai.NewBackend("")). // AI backend defaults to "claude" (matching ai.NewBackend("")).
func SeedFromEnv() Settings { func SeedFromEnv() Settings {
backend := os.Getenv("ANTIDRIFT_AI_BACKEND") backend := os.Getenv("KEEL_AI_BACKEND")
if backend == "" { if backend == "" {
backend = "claude" backend = "claude"
} }
return Settings{ return Settings{
AIBackend: backend, AIBackend: backend,
MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"), MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"), KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
} }
} }
+6 -6
View File
@@ -29,9 +29,9 @@ func TestLoadMissingFileIsError(t *testing.T) {
} }
func TestSeedFromEnvReadsVars(t *testing.T) { func TestSeedFromEnvReadsVars(t *testing.T) {
t.Setenv("ANTIDRIFT_AI_BACKEND", "codex") t.Setenv("KEEL_AI_BACKEND", "codex")
t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am") t.Setenv("KEEL_MARVIN_CMD", "uv run am")
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md") t.Setenv("KEEL_KNOWLEDGE_FILE", "/tmp/k.md")
got := SeedFromEnv() got := SeedFromEnv()
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
if got != want { if got != want {
@@ -40,9 +40,9 @@ func TestSeedFromEnvReadsVars(t *testing.T) {
} }
func TestSeedFromEnvDefaultsBackend(t *testing.T) { func TestSeedFromEnvDefaultsBackend(t *testing.T) {
t.Setenv("ANTIDRIFT_AI_BACKEND", "") t.Setenv("KEEL_AI_BACKEND", "")
t.Setenv("ANTIDRIFT_MARVIN_CMD", "") t.Setenv("KEEL_MARVIN_CMD", "")
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "") t.Setenv("KEEL_KNOWLEDGE_FILE", "")
got := SeedFromEnv() got := SeedFromEnv()
if got.AIBackend != "claude" { if got.AIBackend != "claude" {
t.Errorf("default backend = %q, want claude", got.AIBackend) t.Errorf("default backend = %q, want claude", got.AIBackend)
+67 -15
View File
@@ -1,5 +1,5 @@
// Package statusfile mirrors the controller's runtime status into a single-line // Package statusfile mirrors the harness's runtime status into a single-line
// file (~/.antidrift_status) so an external consumer — a window-manager status // file (~/.keel_status) so an external consumer — a window-manager status
// bar — can display it with a plain file read. // bar — can display it with a plain file read.
package statusfile package statusfile
@@ -9,20 +9,22 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"antidrift/internal/domain" "keel/internal/mode"
"antidrift/internal/session" "keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
) )
// statusFileName is the file written under the user's home directory. // statusFileName is the file written under the user's home directory.
const statusFileName = ".antidrift_status" const statusFileName = ".keel_status"
// interval is how often the writer re-renders. Minute granularity is enough for // interval is how often the writer re-renders. Minute granularity is enough for
// a status bar, so a coarse tick keeps the write rate (and disk churn) low. // a status bar, so a coarse tick keeps the write rate (and disk churn) low.
const interval = time.Minute const interval = time.Minute
// DefaultPath resolves ~/.antidrift_status. // DefaultPath resolves ~/.keel_status.
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -31,10 +33,59 @@ func DefaultPath() (string, error) {
return filepath.Join(home, statusFileName), nil return filepath.Join(home, statusFileName), nil
} }
// Render produces the single status line for the given state. It is empty for // Render produces the single status line for the harness envelope, dispatching
// Locked/idle so a bar module can hide itself. Drift status strings ("drifting") // on the active mode. An idle harness ("") renders "idle"; an unknown kind
// renders a generic fallback so a future mode degrades gracefully rather than
// rendering nothing.
func Render(env mode.Envelope, now time.Time) string {
switch env.ActiveMode {
case "":
return "idle"
case "focus":
st, ok := env.Mode.(focus.State)
if !ok {
// Defensive: the envelope claims focus but carries an unexpected
// payload. Render a safe fallback instead of panicking on the assert.
return "focus"
}
return renderFocus(st, now)
case "offscreen":
return renderOffscreen(env.Mode)
default:
return env.ActiveMode
}
}
// renderOffscreen produces the off-screen status line from the mode's untyped
// View payload (a map[string]any read from the live in-process envelope). It
// shows the proposed next action when one is present, a warning glyph on error,
// and a "thinking…" placeholder otherwise. Any payload mismatch degrades to the
// placeholder rather than panicking.
func renderOffscreen(m any) string {
const thinking = "off-screen: thinking…"
view, ok := m.(map[string]any)
if !ok {
return thinking
}
status, _ := view["status"].(string)
switch status {
case "proposed":
na, _ := view["next_action"].(string)
if na = strings.TrimSpace(na); na != "" {
return "off-screen: " + na
}
return thinking
case "error":
return "off-screen: ⚠"
default:
return thinking
}
}
// renderFocus produces the focus status line. It is empty for Locked/idle focus
// states so a bar module can hide itself. Drift status strings ("drifting")
// match the JSON contract the web UI already consumes. // match the JSON contract the web UI already consumes.
func Render(st session.State, now time.Time) string { func renderFocus(st focus.State, now time.Time) string {
switch st.RuntimeState { switch st.RuntimeState {
case domain.RuntimeActive: case domain.RuntimeActive:
timer := remaining(st, now) timer := remaining(st, now)
@@ -58,7 +109,7 @@ func Render(st session.State, now time.Time) string {
// remaining formats the minutes left until the commitment deadline, rounded up // remaining formats the minutes left until the commitment deadline, rounded up
// so a partial minute still reads as a minute and the count hits 0m only at the // so a partial minute still reads as a minute and the count hits 0m only at the
// end. It returns 0m when no deadline is known. // end. It returns 0m when no deadline is known.
func remaining(st session.State, now time.Time) string { func remaining(st focus.State, now time.Time) string {
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 { if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
return "0m" return "0m"
} }
@@ -70,11 +121,11 @@ func remaining(st session.State, now time.Time) string {
return fmt.Sprintf("%dm", mins) return fmt.Sprintf("%dm", mins)
} }
// Writer periodically renders the controller state and writes it to a file, // Writer periodically renders the harness envelope and writes it to a file,
// rewriting only when the line changes. // rewriting only when the line changes.
type Writer struct { type Writer struct {
path string path string
state func() session.State state func() mode.Envelope
now func() time.Time now func() time.Time
wake chan struct{} wake chan struct{}
@@ -82,13 +133,14 @@ type Writer struct {
wrote bool wrote bool
} }
// NewWriter builds a Writer for path, reading state via the given accessor. // NewWriter builds a Writer for path, reading the harness envelope via the given
func NewWriter(path string, state func() session.State) *Writer { // accessor (typically harness.State).
func NewWriter(path string, state func() mode.Envelope) *Writer {
return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)} return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)}
} }
// Wake asks the writer to re-render now rather than at the next tick. It is the // Wake asks the writer to re-render now rather than at the next tick. It is the
// hook the controller's change notifications fire through, so drift transitions // hook the harness's change notifications fire through, so drift transitions
// reach the status bar promptly instead of lagging the web UI by up to a tick. // reach the status bar promptly instead of lagging the web UI by up to a tick.
// The signal is coalesced (buffered, size 1): a burst of changes collapses into // The signal is coalesced (buffered, size 1): a burst of changes collapses into
// a single re-render, and the actual write still happens on the Run goroutine, // a single re-render, and the actual write still happens on the Run goroutine,
+45 -26
View File
@@ -8,17 +8,28 @@ import (
"testing" "testing"
"time" "time"
"antidrift/internal/domain" "keel/internal/mode"
"antidrift/internal/session" "keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
) )
func activeState(deadline int64, driftStatus, nudge string) session.State { // focusEnv wraps a focus.State in the harness envelope the writer now consumes.
st := session.State{ func focusEnv(st focus.State) mode.Envelope {
return mode.Envelope{ActiveMode: "focus", Mode: st}
}
// focusState builds a focus.State with the given runtime state.
func focusState(rt domain.RuntimeState) focus.State {
return focus.State{RuntimeState: rt}
}
func activeState(deadline int64, driftStatus, nudge string) focus.State {
st := focus.State{
RuntimeState: domain.RuntimeActive, RuntimeState: domain.RuntimeActive,
Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline}, Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline},
} }
if driftStatus != "" || nudge != "" { if driftStatus != "" || nudge != "" {
st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge} st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge}
} }
return st return st
} }
@@ -29,25 +40,33 @@ func TestRender(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
st session.State env mode.Envelope
want string want string
}{ }{
{"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""}, {"idle", mode.Envelope{}, "idle"},
{"empty runtime", session.State{}, ""}, {"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""},
{"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"}, {"empty focus runtime", focusEnv(focus.State{}), ""},
{"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"}, {"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"},
{"active ontask", activeState(in24m, "ontask", ""), "● 24m"}, {"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"},
{"active no drift view", activeState(in24m, "", ""), "● 24m"}, {"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"},
{"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"}, {"active no drift view", focusEnv(activeState(in24m, "", "")), " 24m"},
{"active nudge", activeState(in24m, "ontask", "wandered off"), " 24m ·?"}, {"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"},
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"}, {"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), " 24m ·?"},
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"}, {"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"},
{"active past deadline", activeState(now.Add(-5*time.Minute).Unix(), "ontask", ""), "● 0m"}, {"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"},
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"}, {"active past deadline", focusEnv(activeState(now.Add(-5*time.Minute).Unix(), "ontask", "")), "● 0m"},
{"active partial minute rounds up", focusEnv(activeState(now.Add(10*time.Second).Unix(), "ontask", "")), "● 1m"},
{"unknown kind falls back to kind name", mode.Envelope{ActiveMode: "house"}, "house"},
{"focus with wrong payload falls back", mode.Envelope{ActiveMode: "focus", Mode: "bogus"}, "focus"},
{"offscreen proposed", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "call mum"}}, "off-screen: call mum"},
{"offscreen pending", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "pending"}}, "off-screen: thinking…"},
{"offscreen error", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "error", "error": "boom"}}, "off-screen: ⚠"},
{"offscreen proposed no action", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed"}}, "off-screen: thinking…"},
{"offscreen wrong payload", mode.Envelope{ActiveMode: "offscreen", Mode: "bogus"}, "off-screen: thinking…"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if got := Render(tc.st, now); got != tc.want { if got := Render(tc.env, now); got != tc.want {
t.Errorf("Render() = %q, want %q", got, tc.want) t.Errorf("Render() = %q, want %q", got, tc.want)
} }
}) })
@@ -57,8 +76,8 @@ func TestRender(t *testing.T) {
func TestWriterWritesAndDedups(t *testing.T) { func TestWriterWritesAndDedups(t *testing.T) {
path := filepath.Join(t.TempDir(), "status") path := filepath.Join(t.TempDir(), "status")
now := time.Unix(1_000_000, 0) now := time.Unix(1_000_000, 0)
state := session.State{RuntimeState: domain.RuntimePlanning} env := focusEnv(focusState(domain.RuntimePlanning))
w := NewWriter(path, func() session.State { return state }) w := NewWriter(path, func() mode.Envelope { return env })
w.now = func() time.Time { return now } w.now = func() time.Time { return now }
w.write() w.write()
@@ -77,7 +96,7 @@ func TestWriterWritesAndDedups(t *testing.T) {
} }
// Changed state: rewrites. // Changed state: rewrites.
state = session.State{RuntimeState: domain.RuntimeLocked} env = focusEnv(focusState(domain.RuntimeLocked))
w.write() w.write()
if got := readFile(t, path); got != "" { if got := readFile(t, path); got != "" {
t.Errorf("changed write = %q, want empty", got) t.Errorf("changed write = %q, want empty", got)
@@ -86,8 +105,8 @@ func TestWriterWritesAndDedups(t *testing.T) {
func TestWriterRemovesFileOnCancel(t *testing.T) { func TestWriterRemovesFileOnCancel(t *testing.T) {
path := filepath.Join(t.TempDir(), "status") path := filepath.Join(t.TempDir(), "status")
w := NewWriter(path, func() session.State { w := NewWriter(path, func() mode.Envelope {
return session.State{RuntimeState: domain.RuntimePlanning} return focusEnv(focusState(domain.RuntimePlanning))
}) })
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{}) done := make(chan struct{})
@@ -112,7 +131,7 @@ func TestWriterWakeRendersPromptly(t *testing.T) {
var mu sync.Mutex var mu sync.Mutex
st := activeState(in10m, "ontask", "") st := activeState(in10m, "ontask", "")
w := NewWriter(path, func() session.State { mu.Lock(); defer mu.Unlock(); return st }) w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) })
w.now = func() time.Time { return now } w.now = func() time.Time { return now }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
// Snapshot is the persisted current state of the daemon. // Snapshot is the persisted current state of the daemon.
@@ -29,13 +29,13 @@ type Snapshot struct {
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"` EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
} }
// DefaultPath returns ~/.antidrift/state.json. // DefaultPath returns ~/.keel/state.json.
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, ".antidrift", "state.json"), nil return filepath.Join(home, ".keel", "state.json"), nil
} }
// Load reads a snapshot. A missing file yields a default Locked snapshot. // Load reads a snapshot. A missing file yields a default Locked snapshot.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
func TestLoadMissingFileReturnsLocked(t *testing.T) { func TestLoadMissingFileReturnsLocked(t *testing.T) {
+15 -4
View File
@@ -31,7 +31,8 @@ func execRunner(ctx context.Context, name string, args ...string) ([]byte, error
// (`am --json`, no subcommand) and parses the open tasks due today or earlier. // (`am --json`, no subcommand) and parses the open tasks due today or earlier.
type Marvin struct { type Marvin struct {
cmd string cmd string
args []string base []string // configured prefix after cmd, without the "--json" sentinel
args []string // read-path argv: base + ["--json"]
run runner run runner
} }
@@ -43,9 +44,9 @@ func NewMarvin(command string) *Marvin {
if len(fields) == 0 { if len(fields) == 0 {
fields = []string{"am"} fields = []string{"am"}
} }
args := append([]string{}, fields[1:]...) base := append([]string{}, fields[1:]...)
args = append(args, "--json") args := append(append([]string{}, base...), "--json")
return &Marvin{cmd: fields[0], args: args, run: execRunner} return &Marvin{cmd: fields[0], base: base, args: args, run: execRunner}
} }
// Today returns the open tasks due today or earlier, or an error if the CLI // Today returns the open tasks due today or earlier, or an error if the CLI
@@ -58,6 +59,16 @@ func (m *Marvin) Today(ctx context.Context) ([]Task, error) {
return parse(out) return parse(out)
} }
// Create adds a new task via ampy's `add` subcommand, shelling out through the
// same configured prefix as the read path so the add lands in the same CRDT
// store the reads observe. Only the title is sent in v1. It returns the CLI
// error on failure.
func (m *Marvin) Create(ctx context.Context, t Task) error {
args := append(append([]string{}, m.base...), "add", t.Title, "--json")
_, err := m.run(ctx, m.cmd, args...)
return err
}
type rawTask struct { type rawTask struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
+33
View File
@@ -3,6 +3,7 @@ package tasks
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
) )
@@ -106,3 +107,35 @@ func TestTodayPropagatesError(t *testing.T) {
t.Fatal("want error from failing runner") t.Fatal("want error from failing runner")
} }
} }
func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) {
m := NewMarvin("uv run am")
var gotName string
var gotArgs []string
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
gotName, gotArgs = name, args
return []byte(`{"id":"task-1"}`), nil
}
if err := m.Create(context.Background(), NewTask("buy groceries")); err != nil {
t.Fatalf("Create: %v", err)
}
// Same prefix the read path shells out through, so add goes through the
// same ampy entrypoint and preserves its CRDT bookkeeping.
if gotName != "uv" {
t.Fatalf("runner name = %q, want %q", gotName, "uv")
}
joined := strings.Join(gotArgs, " ")
if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") {
t.Fatalf("args = %v, want marvin add with title", gotArgs)
}
}
func TestCreatePropagatesError(t *testing.T) {
m := NewMarvin("am")
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
return nil, errors.New("am add failed")
}
if err := m.Create(context.Background(), NewTask("buy groceries")); err == nil {
t.Fatal("want error from failing runner")
}
}
+5 -1
View File
@@ -13,7 +13,11 @@ type Task struct {
} }
// Provider answers "what should I be doing?" — the open tasks due today or // Provider answers "what should I be doing?" — the open tasks due today or
// earlier. // earlier — and lets callers add new tasks.
type Provider interface { type Provider interface {
Today(ctx context.Context) ([]Task, error) 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} }
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"antidrift/internal/settings" "keel/internal/settings"
"github.com/gin-gonic/gin" "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 // 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 — // 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). // beyond OS permissions (same trust boundary as the rest of the UI).
func (s *Server) handleBrowse(c *gin.Context) { func (s *Server) handleBrowse(c *gin.Context) {
dir := strings.TrimSpace(c.Query("dir")) dir := strings.TrimSpace(c.Query("dir"))
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"antidrift/internal/settings" "keel/internal/settings"
) )
// fakeApplier records the last settings it was asked to apply and can be told to // 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) { func TestGetSettingsReturnsCurrent(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"} cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
fa := &fakeApplier{} fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply) 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) { func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json") path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{} fa := &fakeApplier{}
s.SetSettings(path, settings.Settings{}, fa.apply) s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -78,7 +78,7 @@ func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
} }
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) { func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json") path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{reject: true} fa := &fakeApplier{reject: true}
s.SetSettings(path, settings.Settings{}, fa.apply) s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -97,7 +97,7 @@ func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
} }
func TestPostSettingsInvalidJSONIs400(t *testing.T) { func TestPostSettingsInvalidJSONIs400(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
fa := &fakeApplier{} fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
r := s.Router() r := s.Router()
@@ -123,7 +123,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
t.Fatal(err) 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 }) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router() r := s.Router()
@@ -179,7 +179,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
} }
func TestBrowseBadDirIs400(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 }) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router() r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil) 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-ghost { background: var(--line); color: var(--ink); }
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; } .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. */ /* 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 { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
.band-actions .btn { margin-top: 0; padding: 7px 12px; } .band-actions .btn { margin-top: 0; padding: 7px 12px; }
@@ -176,3 +180,13 @@ input[type="checkbox"] {
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
} }
.browse-list button:hover { background: var(--line); color: var(--accent); } .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'); const view = document.getElementById('view');
let countdownTimer = null; let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows 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 let dismissedNudge = null; // text of the nudge the user has dismissed
function post(path, body) { 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="ontask" type="button" class="btn btn-ghost">This is on task</button>
<button id="enddrift" type="button" class="btn btn-ghost">End session</button> <button id="enddrift" type="button" class="btn btn-ghost">End session</button>
</div>`; </div>`;
document.getElementById('refocus').onclick = () => post('/refocus'); document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
document.getElementById('ontask').onclick = () => post('/ontask'); document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
document.getElementById('enddrift').onclick = () => post('/complete'); document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
return; return;
} }
@@ -223,7 +224,7 @@ function updatePlanningReflection(refl) {
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`; el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
} }
function render(state) { function renderFocus(state) {
setStateAttr(state); setStateAttr(state);
const rs = state.runtime_state; const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') { if (rs === 'planning' && renderedState === 'planning') {
@@ -248,7 +249,7 @@ function render(state) {
<p class="meta">No active commitment.</p> <p class="meta">No active commitment.</p>
<button id="plan" class="btn btn-primary">Start planning</button> <button id="plan" class="btn btn-primary">Start planning</button>
</div>`; </div>`;
document.getElementById('plan').onclick = () => post('/planning'); document.getElementById('plan').onclick = () => post('/mode/focus/start');
} else if (rs === 'planning') { } else if (rs === 'planning') {
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div> 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'); mins = document.getElementById('mins'), start = document.getElementById('start');
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); }; const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
[na, sc, mins].forEach(el => el.oninput = check); [na, sc, mins].forEach(el => el.oninput = check);
start.onclick = () => post('/commitment', { start.onclick = () => post('/mode/command/commitment', {
next_action: na.value.trim(), next_action: na.value.trim(),
success_condition: sc.value.trim(), success_condition: sc.value.trim(),
timebox_secs: Math.round(+mins.value * 60), timebox_secs: Math.round(+mins.value * 60),
@@ -286,7 +287,7 @@ function render(state) {
}); });
document.getElementById('sharpen').onclick = () => { document.getElementById('sharpen').onclick = () => {
const intent = document.getElementById('intent').value.trim(); const intent = document.getElementById('intent').value.trim();
if (intent) post('/coach', { intent }); if (intent) post('/mode/command/coach', { intent });
}; };
updatePlanningCoach(state.coach); updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
@@ -304,7 +305,7 @@ function render(state) {
</div> </div>
${evidenceBlock(state.evidence)} ${evidenceBlock(state.evidence)}
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`; <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 t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); }; const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick(); tick();
@@ -322,16 +323,82 @@ function render(state) {
${reflectionBlock(state.reflection)} ${reflectionBlock(state.reflection)}
${reviewSummary(state.evidence)} ${reviewSummary(state.evidence)}
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`; <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 { } else {
view.textContent = rs; 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'); const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data)); es.onmessage = (e) => route(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; }; es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; };
// ---- Settings overlay ---- // ---- Settings overlay ----
function openSettings() { function openSettings() {
@@ -349,7 +416,7 @@ function openSettings() {
<input id="setMarvin" placeholder="uv run am"> <input id="setMarvin" placeholder="uv run am">
<label>Knowledge profile path</label> <label>Knowledge profile path</label>
<div class="path-row"> <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> <button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
</div> </div>
<div id="browsePane" class="browse" hidden></div> <div id="browsePane" class="browse" hidden></div>
+2 -2
View File
@@ -3,14 +3,14 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/app.css"> <link rel="stylesheet" href="/app.css">
</head> </head>
<body> <body>
<main id="app"> <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 class="card" id="view">connecting…</div>
<div id="settingsOverlay" class="overlay" hidden></div> <div id="settingsOverlay" class="overlay" hidden></div>
</main> </main>
+65 -87
View File
@@ -7,15 +7,15 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"sync" "sync"
"time" "time"
"antidrift/internal/domain" "keel/internal/harness"
"antidrift/internal/session" "keel/internal/mode/focus/statemachine"
"antidrift/internal/settings" "keel/internal/settings"
"antidrift/internal/statemachine"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -23,9 +23,9 @@ import (
//go:embed static/* //go:embed static/*
var staticFS embed.FS var staticFS embed.FS
// Server wires the session controller to HTTP and SSE. // Server wires the harness to HTTP and SSE.
type Server struct { type Server struct {
ctrl *session.Controller h *harness.Harness
bcast *Broadcaster bcast *Broadcaster
mu sync.Mutex mu sync.Mutex
@@ -38,16 +38,20 @@ type Server struct {
applyMu sync.Mutex // serializes POST /settings apply+save applyMu sync.Mutex // serializes POST /settings apply+save
} }
func NewServer(ctrl *session.Controller) *Server { // NewServer wires the harness to the SSE broadcaster: every harness change
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()} // (mode start, command, async role completion, expiry) fans out the current
ctrl.SetOnChange(s.broadcast) // 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 return s
} }
func (s *Server) Router() *gin.Engine { func (s *Server) Router() *gin.Engine {
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) 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. // forwarded-IP headers. This also silences gin's trust-all-proxies warning.
_ = r.SetTrustedProxies(nil) _ = r.SetTrustedProxies(nil)
@@ -68,13 +72,8 @@ func (s *Server) Router() *gin.Engine {
c.FileFromFS("/favicon.png", http.FS(sub)) c.FileFromFS("/favicon.png", http.FS(sub))
}) })
r.GET("/events", s.handleEvents) r.GET("/events", s.handleEvents)
r.POST("/planning", s.handlePlanning) r.POST("/mode/:kind/start", s.handleStart)
r.POST("/coach", s.handleCoach) r.POST("/mode/command/:name", s.handleCommand)
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.GET("/settings", s.handleGetSettings) r.GET("/settings", s.handleGetSettings)
r.POST("/settings", s.handlePostSettings) r.POST("/settings", s.handlePostSettings)
r.GET("/fs/browse", s.handleBrowse) r.GET("/fs/browse", s.handleBrowse)
@@ -82,7 +81,7 @@ func (s *Server) Router() *gin.Engine {
} }
func (s *Server) stateJSON() string { func (s *Server) stateJSON() string {
data, _ := json.Marshal(s.ctrl.State()) data, _ := json.Marshal(s.h.State())
return string(data) return string(data)
} }
@@ -90,80 +89,58 @@ func (s *Server) broadcast() {
s.bcast.Publish(s.stateJSON()) s.bcast.Publish(s.stateJSON())
} }
// respond maps a controller error to an HTTP status, otherwise broadcasts and // respond maps a harness/mode error to an HTTP status, otherwise returns the
// returns the new state. // 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) { func (s *Server) respond(c *gin.Context, err error) {
if err != nil { if err != nil {
var illegal statemachine.IllegalTransitionError var illegal statemachine.IllegalTransitionError
if errors.As(err, &illegal) { switch {
case errors.As(err, &illegal):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return return
} }
s.broadcast()
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
} }
func (s *Server) handlePlanning(c *gin.Context) { // handleStart activates a mode by kind. Starting focus arms the expiry timer in
s.cancelExpiry() // case the mode is restored or transitions straight into a deadline-bearing
s.respond(c, s.ctrl.EnterPlanning()) // state; the arm is a no-op when there is no deadline yet.
} func (s *Server) handleStart(c *gin.Context) {
kind := c.Param("kind")
type coachRequest struct { err := s.h.Start(kind)
Intent string `json:"intent"` if err == nil && kind == "focus" {
}
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 {
s.armExpiry() s.armExpiry()
} }
s.respond(c, err) s.respond(c, err)
} }
func (s *Server) handleComplete(c *gin.Context) { // 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.cancelExpiry()
s.respond(c, s.ctrl.Complete())
} }
s.respond(c, err)
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())
} }
func (s *Server) handleEvents(c *gin.Context) { 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() { func (s *Server) armExpiry() {
deadline := s.ctrl.Deadline() deadline := s.h.Deadline()
if deadline.IsZero() { if deadline.IsZero() {
return return
} }
@@ -212,9 +192,7 @@ func (s *Server) armExpiry() {
d = 0 d = 0
} }
s.timer = time.AfterFunc(d, func() { s.timer = time.AfterFunc(d, func() {
if err := s.ctrl.Expire(); err == nil { _ = s.h.Expire()
s.broadcast()
}
}) })
} }
@@ -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() { func (s *Server) Init() {
st := s.ctrl.State() deadline := s.h.Deadline()
if st.RuntimeState != "active" { if deadline.IsZero() {
return return
} }
if s.ctrl.Deadline().After(time.Now()) { if deadline.After(time.Now()) {
s.armExpiry() s.armExpiry()
return return
} }
if err := s.ctrl.Expire(); err == nil { _ = s.h.Expire()
s.broadcast()
}
} }
+190 -88
View File
@@ -9,26 +9,46 @@ import (
"testing" "testing"
"time" "time"
"antidrift/internal/ai" "keel/internal/ai"
"antidrift/internal/domain" "keel/internal/evidence"
"antidrift/internal/evidence" "keel/internal/harness"
"antidrift/internal/knowledge" "keel/internal/knowledge"
"antidrift/internal/session" "keel/internal/mode"
"antidrift/internal/settings" "keel/internal/mode/focus"
"antidrift/internal/tasks" "keel/internal/mode/focus/domain"
"keel/internal/settings"
"keel/internal/tasks"
"github.com/gin-gonic/gin" "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() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
ctrl, err := session.New(path) m, err := focus.New(path)
if err != nil { 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 { 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 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) { func TestPlanningThenCommitmentReachesActive(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() 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) t.Fatalf("/planning code %d", w.Code)
} }
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` 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()) t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
} }
if s.ctrl.State().RuntimeState != domain.RuntimeActive { if m.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState) t.Fatalf("expected Active, got %s", m.State().RuntimeState)
} }
} }
func TestCommitmentRejectsInvalidInput(t *testing.T) { func TestCommitmentRejectsInvalidInput(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}` 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 { if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
func TestIllegalTransitionReturns409(t *testing.T) { func TestIllegalTransitionReturns409(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
// /complete from Locked is illegal. // /complete from Locked is illegal.
w := post(t, r, "/complete", "") w := post(t, r, "/mode/command/complete", "")
if w.Code != http.StatusConflict { if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code) 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) { func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() 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}` 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()) t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
} }
// A focus update should appear in the serialized state. // A focus update should appear in the serialized state envelope.
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}}) m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
js := s.stateJSON() js := s.stateJSON()
if !strings.Contains(js, `"evidence"`) { if !strings.Contains(js, `"evidence"`) {
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
} }
func TestLockedStateHasNullEvidence(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() js := s.stateJSON()
if !strings.Contains(js, `"evidence":null`) { if !strings.Contains(js, `"evidence":null`) {
t.Fatalf("locked payload should have null evidence: %s", js) 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) { func TestCoachRouteHappyPath(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { 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()) t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { 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 return
} }
time.Sleep(5 * time.Millisecond) 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) { func TestCoachRouteOutsidePlanning(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetCoach(stubCoach{}) m.SetCoach(stubCoach{})
r := s.Router() 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) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
func TestCoachRouteInvalidJSON(t *testing.T) { func TestCoachRouteInvalidJSON(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest { if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
func TestCoachRouteUnavailableDegrades(t *testing.T) { func TestCoachRouteUnavailableDegrades(t *testing.T) {
s := newTestServer(t) // no SetCoach s, m := newTestServer(t) // no SetCoach
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK { 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) 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) t.Fatalf("want error status, got %+v", cv)
} }
} }
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) { func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() 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) 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) 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) { func TestActiveStatePayloadCarriesNudge(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"}) m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
r := s.Router() 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"]}` 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()) 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 // Two distinct on-task titles trip the nudge (history >= 2; first call has no
// debounce to wait on). // debounce to wait on).
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}}) m.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: "b.go", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { 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 break
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
} }
func TestServesStaticAssets(t *testing.T) { func TestServesStaticAssets(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
cases := []struct { cases := []struct {
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return s.list, nil return s.list, nil
} }
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
func TestPlanningStatePayloadCarriesTasks(t *testing.T) { func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}}) m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
r := s.Router() 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) t.Fatalf("/planning code %d", w.Code)
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { 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 break
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
} }
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) { func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() 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"]}` 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()) 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) t.Fatalf("commitment did not carry allowed classes: %#v", got)
} }
// Now Active: overrides succeed. // 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) 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) 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) { func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}}) m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
r := s.Router() 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) t.Fatalf("/planning code %d", w.Code)
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { 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 break
} }
time.Sleep(5 * time.Millisecond) 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) { func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}}) m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}` 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()) 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) t.Fatalf("/complete code %d", w.Code)
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { 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 break
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
t.Fatalf("review payload missing recap: %s", js) t.Fatalf("review payload missing recap: %s", js)
} }
// End -> Locked -> Planning: the carry-forward should surface on planning. // End -> Locked: the harness releases the focus mode and goes idle. Re-start
if w := post(t, r, "/end", ""); w.Code != http.StatusOK { // 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) 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) t.Fatalf("/planning code %d", w.Code)
} }
js2 := s.stateJSON() js2 := s.stateJSON()
@@ -372,19 +424,19 @@ func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title stri
} }
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) { func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() 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) t.Fatalf("/planning code %d", w.Code)
} }
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}` 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()) 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) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -397,15 +449,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
} }
func TestKnowledgePathSelectionReloads(t *testing.T) { func TestKnowledgePathSelectionReloads(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
settings.Settings{}, func(ss settings.Settings) error { settings.Settings{}, func(ss settings.Settings) error {
s.ctrl.SetKnowledgePath(ss.KnowledgePath) m.SetKnowledgePath(ss.KnowledgePath)
return nil return nil
}) })
r := s.Router() 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) 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 { 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) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { 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 return
} }
time.Sleep(5 * time.Millisecond) 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)
}
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Package winapi is the Windows Win32 binding layer for AntiDrift's OS ports. // Package winapi is the Windows Win32 binding layer for Keel's OS ports.
// The syscall-bound code lives in windows-tagged files; this untagged file // The syscall-bound code lives in windows-tagged files; this untagged file
// holds the pure path logic so it builds and is tested on every platform. // holds the pure path logic so it builds and is tested on every platform.
package winapi package winapi