diff --git a/.gitignore b/.gitignore index 58c31b8..bd03da3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ .superpowers/ # Go -/antidriftd +/keeld *.test diff --git a/AGENTS.md b/AGENTS.md index c63f39f..cfed848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ acts on the reply. DB / event store. Buckets hold *pointers*, not copies. - **Frame** — `~/owc` (values, goals, `resources/bug-*.md` life-bugs). Reference, 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 Beeminder datapoints, `~/owc` notes). Start propose-and-confirm; trend toward 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. ```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 ./... ``` ## Naming / rename status -The directory and git repo are **`keel`**. The **code identity is intentionally -still `antidrift`** — Go module `antidrift`, `antidriftd` binary, `~/.antidrift/` -runtime, `ANTIDRIFT_*` env. The code rename lands later, *with a state migration* -(`~/.antidrift/` owns the live ledger), during the controller refactor. Don't -`mv ~/.antidrift` or rename the module casually. +The rename is **complete**. Directory, git repo, Go module, binary, runtime, and +env are all `keel`: module `keel`, binary `keeld`, runtime `~/.keel/`, +`KEEL_*` env. If you have a live ledger under the old `~/.antidrift/` directory, +move it manually to `~/.keel/` — there is no automated migration tool. ## How to work here diff --git a/README.md b/README.md index 9b6753c..892b3fa 100644 --- a/README.md +++ b/README.md @@ -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 > 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 -focus operating system that treats each work session as an explicit commitment -(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. +Keel's controller is now a generic **harness** that hosts one *mode* at a time, +and the daemon ships two: -> **Naming:** the directory and git repo are renamed to `keel`; the *code identity* -> is intentionally still `antidrift` (Go module, `antidriftd` binary, `~/.antidrift/` -> runtime, `ANTIDRIFT_*` env). The code rename lands with a state migration during -> the controller refactor — see `docs/keel-architecture.md` §8. +- **Focus (AntiDrift)** — Keel's first mode: a personal focus operating system + that treats each work session as an explicit commitment (next action, success + condition, timebox) and makes drift visible. The milestone history below is + 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 ```bash -go run ./cmd/antidriftd +go run ./cmd/keeld ``` 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//` (e.g. +`~/.keel/modes/focus/state.json`). ## 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. M6 (knowledge port): the planning coach now sharpens intents against who you -actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable -via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities, +actually are. A single profile file (`~/.keel/knowledge.md`, overridable +via `KEEL_KNOWLEDGE_FILE`) holds your standing context — priorities, 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 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" 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 works. diff --git a/cmd/antidriftd/main.go b/cmd/antidriftd/main.go deleted file mode 100644 index 81ea200..0000000 --- a/cmd/antidriftd/main.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/keeld/main.go b/cmd/keeld/main.go new file mode 100644 index 0000000..782ecaf --- /dev/null +++ b/cmd/keeld/main.go @@ -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/; 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) + } +} diff --git a/docs/keel-architecture.md b/docs/keel-architecture.md index 290c2a7..15e3eea 100644 --- a/docs/keel-architecture.md +++ b/docs/keel-architecture.md @@ -18,12 +18,11 @@ status: authoritative > **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* > 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 -> focus harness becomes Keel's first *mode*. The directory and git repo are -> renamed; the **code identity is intentionally still `antidrift`** (Go module -> `antidrift`, binary `antidriftd`, runtime `~/.antidrift/`, `ANTIDRIFT_*` env). -> That *code* rename is **deferred** until the controller refactor (§8) because -> `~/.antidrift/` owns the live ledger/state and needs a migration, not a `mv`. +> keeps you on an even keel (steady mind). **This repo is `keel`** — AntiDrift's +> focus harness becomes Keel's first *mode*. The rename is **complete**: directory, +> git repo, Go module (`keel`), binary (`keeld`), runtime (`~/.keel/`), and env +> (`KEEL_*`) are all `keel`. The old `~/.antidrift/` ledger needs a one-time manual +> move to `~/.keel/` for existing installs. > Moved from `~/dev/higher/` on 2026-06-04 — that directory was the scaffold where > 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 harness for one plane. Its controller is welded to *commitment = next_action + success_condition + timebox*. We loosen that into a general **collect → brain → - act** loop where a focus-session is just **one mode**. Other modes: house, - body, review, capture, planning. The core architecture is salvageable — we - widen scope, we don't rebuild. - - Worked example (a non-focus mode): from the phone web UI, *"what work on the - house should I do next?"* → the harness reads the house bugs/tasks, the brain - proposes a strategy, the UI asks Felix for feedback, and on approval an - effector files the task. + act** loop where a focus-session is just **one mode**. The harness now hosts a + second, **off-screen** mode; further modes (body, review, capture) are + aspirational. The core architecture is salvageable — we widen scope, we don't + rebuild. + - Worked example (the shipped non-focus mode): from the phone web UI, *"what + worthwhile off-screen thing should I do now?"* → the harness reads today's + 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 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 *talks to* Keel. - **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.) 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: `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 `daily.db` (+ `:2200`); Beeminder `beeline`; Marvin `ammcp` MCP / `am --json`. 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 | | Memory | ephemeral (dies in `state.json`) | **AW buckets** (durable, queryable) | | 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 | **Loosening the controller** concretely means: generalize `Commitment` from a work-session into a *mode invocation* (a mode has its own collectors, frame slice, brain prompt, and allowed effectors); the focus-session remains the -`next_action/success_condition/timebox` mode; a "house" mode reads house bugs and -proposes a strategy; a "capture" mode just routes a thought. The runtime state -machine becomes the loop in §3. +`next_action/success_condition/timebox` mode; the off-screen mode reads the +life-domain bugs + goals and proposes one off-screen action; a future "capture" +mode would just route a thought. The runtime state machine becomes the loop in §3. --- ## 5. One loop, concrete -House mode, from the phone, end to end: -1. **Collect** — `~/owc/resources/bug-*.md` house items + Marvin "house" tasks + - (memory) what was proposed/done last weekend. -2. **Remember** — read Keel's AW `keel.state` bucket for prior house proposals so - it doesn't repeat itself. -3. **Assemble** — those + the `house-integrity` goal from `~/owc/goals-2026.md`. -4. **Brain** — chosen brain returns a proposed next house task + short strategy. -5. **Surface** — web UI shows the proposal and asks for feedback. -6. **Act** — on approval, the Marvin effector files the task; Keel writes an - `action_taken` event to its AW bucket (memory closes the loop). +Off-screen mode, from the phone, end to end — **this is what ships today**: +1. **Collect** — today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md` + goals + the life-domain `~/owc/resources/bug-*.md` notes, assembled into a + single budgeted brief. +2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen + action plus a short rationale. +3. **Surface** — the phone-first web UI renders a proposal card and asks Felix to + confirm or dismiss. +4. **Act** — on confirm, the Marvin `Create` effector files the action as a task + (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. +**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) @@ -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 | |---|---|---|---|---| | 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) | | Consume | `GET :8000/api` | (later) | intake/reflection signal | item frontmatter | | 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 `~/owc/stream.md` vs `~/wrk/pkb/stream.md` vs dead inboxes) and rewiring `blurt` 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). - **Secret hygiene** — an increasingly autonomous operator runs as the full Unix 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//`. + --- ## 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) -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 -collector (`~/owc` house bugs + Marvin), the brain, the web UI proposal, and the -Marvin effector behind a confirm gate. It exercises every Keel component once, on -the smallest mode, and produces a real filed task — interaction and action, not a -coaching sentence. +To stay honest to "ship visible proof" without boiling the ocean, the first slice +is **off-screen mode, end to end** (§5) on the existing AntiDrift web surface — +collectors (`~/owc` goals + life-domain bugs + Marvin), the brain (`ai.Proposer`), +the phone-first web UI proposal card, and the Marvin `Create` effector behind a +confirm gate. It exercises every Keel component once except memory, on the +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. diff --git a/docs/superpowers/plans/2026-06-04-controller-refactor.md b/docs/superpowers/plans/2026-06-04-controller-refactor.md new file mode 100644 index 0000000..b0d900d --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-controller-refactor.md @@ -0,0 +1,1638 @@ +# Controller Refactor — Harness + Modes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the focus-only `session.Controller` god-object with a generic `harness` that hosts swappable `mode`s; re-home focus as the first mode (behavior-preserving) and add off-screen mode as the second; complete the `antidrift`→`keel` rename clean-slate. + +**Architecture:** A thin `Mode` interface plus optional capability interfaces (`EvidenceConsumer`, `Expirer`). The `harness.Harness` owns shared `Services` (the existing ports + the relocated async primitive + change-notify) and exactly one active mode, routing evidence/expiry/commands to it. Focus is a near-mechanical extraction guarded by its existing tests; off-screen is a small one-shot collect→propose→confirm mode. + +**Tech Stack:** Go 1.26, gin (web), SSE broadcaster, ports-and-adapters (`ai`/`tasks`/`knowledge`/`evidence`/`enforce`). + +**Source spec:** `docs/superpowers/specs/2026-06-04-controller-refactor-design.md` + +**Build order (each phase leaves the daemon runnable):** +- **Phase 0** — Rename `antidrift`→`keel` in place. Pure rename; tests stay green. +- **Phase 1** — Add the `mode` contract + `harness` (additive; old controller untouched). +- **Phase 2** — Migrate focus onto the harness; delete the old `session` wiring. +- **Phase 3** — Add off-screen mode (new `ai.Proposer` role + `tasks` create effector). +- **Phase 4** — Generalize the web/status surfaces under the state envelope. + +--- + +## Phase 0 — Rename antidrift → keel + +No behavior change, no structural change. The daemon stays focus-only. Each task ends with the full suite green. + +### Task 1: Rename the Go module and all import paths + +**Files:** +- Modify: `go.mod` (module line) +- Modify: all 21 `.go` files importing `antidrift/internal` + +- [ ] **Step 1: Rewrite the module path** + +Run: +```bash +go mod edit -module keel +grep -rl 'antidrift/internal' --include='*.go' . | xargs sed -i 's#antidrift/internal#keel/internal#g' +gofmt -w . +``` + +- [ ] **Step 2: Verify build and tests** + +Run: `go build ./... && go test ./...` +Expected: all packages build; all tests PASS (X11 integration tests may skip when no display — that is fine). + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: rename Go module antidrift -> keel" +``` + +### Task 2: Rename runtime paths, status file, and env vars + +**Files:** +- Modify: `internal/store/store.go:32,38` (`.antidrift`→`.keel`, comment) +- Modify: `internal/settings/settings.go:2,20,27,33,67,71,77,78` (`.antidrift`→`.keel`, `ANTIDRIFT_*`→`KEEL_*`, comments) +- Modify: `internal/statusfile/statusfile.go:2,19,25` (`.antidrift_status`→`.keel_status`, comments) +- Modify: `internal/knowledge/file.go:25,49,58` (`.antidrift`→`.keel`, comments) +- Modify: `internal/web/settings_handlers.go:84`, `internal/web/web.go:50` (comments only) +- Test: `internal/settings/settings_test.go` (env var literals) + +- [ ] **Step 1: Replace the literals** + +Edit each file, replacing every `.antidrift` with `.keel` and every `ANTIDRIFT_` with `KEEL_`. The three env vars become `KEEL_AI_BACKEND`, `KEEL_MARVIN_CMD`, `KEEL_KNOWLEDGE_FILE`. The status-file const becomes `const statusFileName = ".keel_status"`. + +- [ ] **Step 2: Update the settings test env literals** + +In `internal/settings/settings_test.go`, replace any `ANTIDRIFT_*` env names with their `KEEL_*` equivalents so `TestSeedFromEnv` (or equivalent) still exercises the live names. + +- [ ] **Step 3: Verify** + +Run: `go test ./internal/settings/... ./internal/store/... ./internal/statusfile/... ./internal/knowledge/...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: move runtime dir/status/env to keel (~/.keel, KEEL_*)" +``` + +### Task 3: Rename the binary cmd/antidriftd → cmd/keeld + +**Files:** +- Rename: `cmd/antidriftd/` → `cmd/keeld/` +- Modify: `cmd/keeld/main.go:1,116` (package doc, listen log) + +- [ ] **Step 1: Move and edit** + +Run: +```bash +git mv cmd/antidriftd cmd/keeld +``` +Then in `cmd/keeld/main.go` change the leading comment `Command antidriftd is the AntiDrift focus daemon` to `Command keeld is the Keel daemon`, and the log line `antidriftd listening on http://%s` to `keeld listening on http://%s`. + +- [ ] **Step 2: Verify** + +Run: `go build ./cmd/keeld && go vet ./...` +Expected: builds; no vet errors. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: rename binary antidriftd -> keeld" +``` + +### Task 4: Update docs to reflect the completed rename + +**Files:** +- Modify: `AGENTS.md` (the `CLAUDE.md` symlink target) — naming-status section + `go run` command +- Modify: `README.md` — `go run ./cmd/antidriftd` → `go run ./cmd/keeld` +- Modify: `docs/keel-architecture.md` §1, §8 (rename now done, not deferred) + +- [ ] **Step 1: Edit the three docs** + +In `AGENTS.md`, replace the "code identity is intentionally still `antidrift`" paragraph with a note that the rename is complete (module `keel`, `keeld` binary, `~/.keel/` runtime, `KEEL_*` env). Update the `go run ./cmd/antidriftd` example to `go run ./cmd/keeld`. Do the same `go run` fix in `README.md`. In `docs/keel-architecture.md` §8, remove "Controller refactor shape" from open questions and move the rename to resolved; fix the §1 note that says only the code identity is still antidrift. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "docs: mark antidrift->keel rename complete" +``` + +--- + +## Phase 1 — The mode contract + harness (additive) + +New packages, fully unit-tested against a fake mode. The old `session`-based daemon keeps running unchanged; nothing wires the harness yet. + +### Task 5: Define the Mode contract + +**Files:** +- Create: `internal/mode/mode.go` +- Test: (covered by harness tests in Task 9) + +- [ ] **Step 1: Write the contract** + +```go +// Package mode defines the contract the harness uses to host a single +// collect→brain→act unit. Focus and off-screen are implementations. +package mode + +import ( + "context" + "encoding/json" + "time" + + "keel/internal/evidence" +) + +// Mode is one collect→brain→act configuration. The harness runs at most one. +type Mode interface { + // Kind identifies the mode ("focus", "offscreen"). + Kind() string + // Command routes a surface-issued command into the mode. It returns an + // error for illegal/invalid commands; the web layer maps it to HTTP. + Command(ctx context.Context, name string, payload json.RawMessage) error + // View projects the mode's current state for surfaces (JSON-marshalable). + View() any + // Active reports whether the mode still holds the harness. When it returns + // false the harness releases the mode and goes idle. + Active() bool +} + +// EvidenceConsumer is implemented by modes that read the window sensor stream. +type EvidenceConsumer interface { + OnWindow(evidence.WindowSnapshot) +} + +// Expirer is implemented by modes with a server-authoritative deadline. Expire +// takes no context to match focus's existing Expire() error method, so focus +// satisfies this interface with zero changes to its tested surface. +type Expirer interface { + Deadline() time.Time + Expire() error +} + +// Envelope is the surfacing wrapper: the active mode's kind plus its View(). +type Envelope struct { + ActiveMode string `json:"active_mode"` // "" = idle + Mode any `json:"mode,omitempty"` +} +``` + +- [ ] **Step 2: Verify build** + +Run: `go build ./internal/mode/` +Expected: builds. + +- [ ] **Step 3: Commit** + +```bash +git add internal/mode/mode.go +git commit -m "feat: add mode contract (Mode, capability interfaces, Envelope)" +``` + +### Task 6: Relocate the async primitive into the harness + +**Files:** +- Create: `internal/harness/async.go` +- Test: `internal/harness/async_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package harness + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestAsyncRunAppliesAndNotifies(t *testing.T) { + var mu sync.Mutex + notified := make(chan struct{}, 1) + a := NewAsync(&mu, func() { notified <- struct{}{} }) + + var applied bool + a.Run(time.Second, + func(ctx context.Context) {}, + func() bool { return false }, + func() { applied = true }, + ) + select { + case <-notified: + case <-time.After(time.Second): + t.Fatal("notify never fired") + } + mu.Lock() + defer mu.Unlock() + if !applied { + t.Fatal("apply did not run") + } +} + +func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) { + var mu sync.Mutex + a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") }) + done := make(chan struct{}) + a.Run(time.Second, + func(ctx context.Context) {}, + func() bool { close(done); return true }, + func() { t.Fatal("apply ran on stale result") }, + ) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("stale guard never evaluated") + } + time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/harness/ -run TestAsync` +Expected: FAIL (package/`NewAsync` not defined). + +- [ ] **Step 3: Implement the primitive** + +```go +// Package harness is the generic host: it owns shared services and exactly one +// active mode, and routes evidence, expiry, and commands to it. +package harness + +import ( + "context" + "sync" + "time" +) + +// Async runs generation-guarded background work for a mode. It preserves the +// lock discipline of the original session.runFetchAsync: fetch runs with no +// lock held; stale and apply run under the mode's mutex; notify fires once, +// after the lock is released. +type Async struct { + mu *sync.Mutex + notify func() +} + +// NewAsync binds the helper to a mode's mutex and the harness change-notify. +func NewAsync(mu *sync.Mutex, notify func()) Async { + return Async{mu: mu, notify: notify} +} + +// Run launches a background fetch. stale returns true to DISCARD the result +// (a newer generation superseded it). apply records a non-stale result under +// the mutex and must NOT call notify — Run owns the post-unlock notify. +func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + fetch(ctx) + a.mu.Lock() + if stale() { + a.mu.Unlock() + return + } + apply() + a.mu.Unlock() + if a.notify != nil { + a.notify() + } + }() +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/harness/ -run TestAsync` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/harness/async.go internal/harness/async_test.go +git commit -m "feat: relocate runFetchAsync as harness.Async" +``` + +### Task 7: Define the Services handle + +**Files:** +- Create: `internal/harness/services.go` + +- [ ] **Step 1: Write the Services struct** + +```go +package harness + +import ( + "time" + + "keel/internal/ai" + "keel/internal/enforce" + "keel/internal/knowledge" + "keel/internal/tasks" +) + +// Services is the shared infrastructure every mode receives from the harness. +// Dir is the mode's namespaced persistence directory (~/.keel/modes/); +// modes build their own file paths under it. Notify fires the harness change +// listeners (web SSE + status bar). +type Services struct { + AI *ai.Service + Tasks tasks.Provider + Knowledge knowledge.Source + Enforce enforce.Guard + Clock func() time.Time + Dir string + Notify func() +} +``` + +> Note: the spec sketched `Store *store.Scope`; the plan uses `Dir string` instead — the existing `store` functions already take explicit paths, so a base dir is the smallest thing that satisfies "mode-namespaced persistence" (YAGNI). + +- [ ] **Step 2: Verify build** + +Run: `go build ./internal/harness/` +Expected: builds. + +- [ ] **Step 3: Commit** + +```bash +git add internal/harness/services.go +git commit -m "feat: add harness.Services handle" +``` + +### Task 8: Implement the Harness host + +**Files:** +- Create: `internal/harness/harness.go` + +- [ ] **Step 1: Write the host** + +```go +package harness + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "keel/internal/evidence" + "keel/internal/mode" +) + +var ( + ErrBusy = errors.New("harness: another mode is active") + ErrUnknownMode = errors.New("harness: unknown mode") + ErrIdle = errors.New("harness: no active mode") +) + +// Factory builds a mode from the shared services, in its initial active state. +type Factory func(Services) mode.Mode + +type Harness struct { + mu sync.Mutex + active mode.Mode + factories map[string]Factory + services Services + onChange []func() +} + +// New wires Services.Notify to the harness so every mode's async completion +// reaches all listeners, then stores the services for the factories to receive. +func New(services Services, factories map[string]Factory) *Harness { + h := &Harness{factories: factories} + services.Notify = h.notify + h.services = services + return h +} + +// AddOnChange registers a change listener (web broadcaster, status writer). +func (h *Harness) AddOnChange(f func()) { + h.mu.Lock() + h.onChange = append(h.onChange, f) + h.mu.Unlock() +} + +// SetServices swaps the services future Start calls hand to factories. In v1 it +// affects the NEXT mode start only; an already-active mode keeps the services it +// was built with (settings changes take effect on the next session). +func (h *Harness) SetServices(s Services) { + s.Notify = h.notify + h.mu.Lock() + h.services = s + h.mu.Unlock() +} + +func (h *Harness) notify() { + h.mu.Lock() + fs := append([]func(){}, h.onChange...) + h.mu.Unlock() + for _, f := range fs { + if f != nil { + f() + } + } +} + +// Adopt installs an already-constructed mode as active (startup restoration). +// It fails if a mode is already active. +func (h *Harness) Adopt(m mode.Mode) error { + h.mu.Lock() + if h.active != nil { + h.mu.Unlock() + return ErrBusy + } + h.active = m + h.mu.Unlock() + h.notify() + return nil +} + +// Start activates a mode by kind. Idempotent if that kind is already active; +// ErrBusy if a different kind is active. +func (h *Harness) Start(kind string) error { + h.mu.Lock() + if h.active != nil { + busy := h.active.Kind() != kind + h.mu.Unlock() + if busy { + return ErrBusy + } + return nil + } + f, ok := h.factories[kind] + if !ok { + h.mu.Unlock() + return ErrUnknownMode + } + h.active = f(h.services) + h.mu.Unlock() + h.notify() + return nil +} + +// Command routes to the active mode, then releases it if it went inactive. +func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error { + h.mu.Lock() + m := h.active + h.mu.Unlock() + if m == nil { + return ErrIdle + } + if err := m.Command(ctx, name, payload); err != nil { + return err + } + h.releaseIfDone() + h.notify() + return nil +} + +func (h *Harness) releaseIfDone() { + h.mu.Lock() + if h.active != nil && !h.active.Active() { + h.active = nil + } + h.mu.Unlock() +} + +// State returns the surfacing envelope. +func (h *Harness) State() mode.Envelope { + h.mu.Lock() + m := h.active + h.mu.Unlock() + if m == nil { + return mode.Envelope{} + } + return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()} +} + +// RecordWindow forwards a window snapshot iff the active mode consumes evidence. +func (h *Harness) RecordWindow(w evidence.WindowSnapshot) { + h.mu.Lock() + m := h.active + h.mu.Unlock() + if c, ok := m.(mode.EvidenceConsumer); ok { + c.OnWindow(w) + } +} + +// Deadline returns the active mode's deadline iff it is an Expirer. +func (h *Harness) Deadline() time.Time { + h.mu.Lock() + m := h.active + h.mu.Unlock() + if e, ok := m.(mode.Expirer); ok { + return e.Deadline() + } + return time.Time{} +} + +// Expire fires the active mode's expiry iff it is an Expirer, then releases it +// if it went inactive. +func (h *Harness) Expire() error { + h.mu.Lock() + m := h.active + h.mu.Unlock() + e, ok := m.(mode.Expirer) + if !ok { + return ErrIdle + } + if err := e.Expire(); err != nil { + return err + } + h.releaseIfDone() + h.notify() + return nil +} +``` + +- [ ] **Step 2: Verify build** + +Run: `go build ./internal/harness/` +Expected: builds. + +- [ ] **Step 3: Commit** + +```bash +git add internal/harness/harness.go +git commit -m "feat: implement harness host (single-active mode router)" +``` + +### Task 9: Harness behavior tests with a fake mode + +**Files:** +- Test: `internal/harness/harness_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +package harness + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "keel/internal/evidence" + "keel/internal/mode" +) + +// fakeMode implements Mode and (optionally) the capability interfaces. +type fakeMode struct { + mu sync.Mutex + kind string + active bool + cmds []string + windows int + deadline time.Time + expired bool + evidence bool // implements EvidenceConsumer when true + expirer bool // implements Expirer when true +} + +func (f *fakeMode) Kind() string { return f.kind } +func (f *fakeMode) View() any { return map[string]bool{"active": f.active} } +func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active } +func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error { + f.mu.Lock() + defer f.mu.Unlock() + f.cmds = append(f.cmds, name) + if name == "finish" { + f.active = false + } + return nil +} + +type evidenceMode struct{ *fakeMode } +func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() } + +type expirerMode struct{ *fakeMode } +func (e expirerMode) Deadline() time.Time { return e.deadline } +func (e expirerMode) Expire() error { e.mu.Lock(); e.expired = true; e.active = false; e.mu.Unlock(); return nil } + +func newHarness(m mode.Mode) *Harness { + return New(Services{Clock: time.Now}, map[string]Factory{ + m.Kind(): func(Services) mode.Mode { return m }, + }) +} + +func TestStartSingleActiveInvariant(t *testing.T) { + h := New(Services{}, map[string]Factory{ + "a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} }, + "b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} }, + }) + if err := h.Start("a"); err != nil { + t.Fatalf("Start a: %v", err) + } + if err := h.Start("a"); err != nil { + t.Fatalf("re-Start a should be idempotent: %v", err) + } + if err := h.Start("b"); err != ErrBusy { + t.Fatalf("Start b while a active = %v, want ErrBusy", err) + } + if got := h.State().ActiveMode; got != "a" { + t.Fatalf("ActiveMode = %q, want a", got) + } +} + +func TestCommandReleasesWhenInactive(t *testing.T) { + m := &fakeMode{kind: "a", active: true} + h := newHarness(m) + _ = h.Start("a") + if err := h.Command(context.Background(), "finish", nil); err != nil { + t.Fatalf("Command: %v", err) + } + if got := h.State().ActiveMode; got != "" { + t.Fatalf("after finish ActiveMode = %q, want idle", got) + } +} + +func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) { + plain := &fakeMode{kind: "plain", active: true} + h := newHarness(plain) + _ = h.Start("plain") + h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic + if plain.windows != 0 { + t.Fatalf("plain mode received %d windows, want 0", plain.windows) + } + + ev := evidenceMode{&fakeMode{kind: "ev", active: true}} + h2 := newHarness(ev) + _ = h2.Start("ev") + h2.RecordWindow(evidence.WindowSnapshot{}) + if ev.windows != 1 { + t.Fatalf("evidence mode received %d windows, want 1", ev.windows) + } +} + +func TestExpiryOnlyForExpirers(t *testing.T) { + plain := &fakeMode{kind: "plain", active: true} + h := newHarness(plain) + _ = h.Start("plain") + if err := h.Expire(); err != ErrIdle { + t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err) + } + if !h.Deadline().IsZero() { + t.Fatal("non-expirer Deadline should be zero") + } + + ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}} + h2 := newHarness(ex) + _ = h2.Start("ex") + if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) { + t.Fatalf("Deadline = %v, want unix 100", got) + } + if err := h2.Expire(); err != nil { + t.Fatalf("Expire: %v", err) + } + if got := h2.State().ActiveMode; got != "" { + t.Fatalf("after Expire ActiveMode = %q, want idle", got) + } +} + +func TestCommandWhileIdle(t *testing.T) { + h := New(Services{}, map[string]Factory{}) + if err := h.Command(context.Background(), "x", nil); err != ErrIdle { + t.Fatalf("Command while idle = %v, want ErrIdle", err) + } +} +``` + +- [ ] **Step 2: Run to verify it fails, then passes** + +Run: `go test ./internal/harness/ -v` +Expected: compiles and PASS (the host from Task 8 already satisfies these). + +- [ ] **Step 3: Commit** + +```bash +git add internal/harness/harness_test.go +git commit -m "test: harness single-active, routing, capability assertion" +``` + +--- + +## Phase 2 — Migrate focus onto the harness + +Move the focus implementation into `mode/focus`, adapt it to satisfy `mode.Mode`, and rewire `main`/`web` to drive the harness. The **existing focus tests move with the code and must pass unchanged** — they are the regression oracle. This phase ends with the old `session` package deleted. + +### Task 10: Move domain and statemachine under focus + +**Files:** +- Rename: `internal/domain/` → `internal/mode/focus/domain/` +- Rename: `internal/statemachine/` → `internal/mode/focus/statemachine/` +- Modify: every importer of `keel/internal/domain` and `keel/internal/statemachine` + +- [ ] **Step 1: Move and rewrite imports** + +Run: +```bash +mkdir -p internal/mode/focus +git mv internal/domain internal/mode/focus/domain +git mv internal/statemachine internal/mode/focus/statemachine +grep -rl 'keel/internal/domain' --include='*.go' . | xargs sed -i 's#keel/internal/domain#keel/internal/mode/focus/domain#g' +grep -rl 'keel/internal/statemachine' --include='*.go' . | xargs sed -i 's#keel/internal/statemachine#keel/internal/mode/focus/statemachine#g' +gofmt -w . +``` + +- [ ] **Step 2: Verify** + +Run: `go build ./... && go test ./internal/mode/focus/domain/... ./internal/mode/focus/statemachine/...` +Expected: builds; PASS. (`store`, `statusfile`, `web`, `session` still import these via the new paths — that is fine for now.) + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: move domain + statemachine under mode/focus" +``` + +### Task 11: Move the session package to mode/focus and rename the type + +**Files:** +- Rename: `internal/session/` → `internal/mode/focus/` (merge alongside `domain/`, `statemachine/`) +- Modify: package declaration `package session` → `package focus`; type `Controller` → `Mode` + +- [ ] **Step 1: Move the files** + +Run: +```bash +git mv internal/session/session.go internal/mode/focus/focus.go +git mv internal/session/roles.go internal/mode/focus/roles.go +git mv internal/session/drift.go internal/mode/focus/drift.go +git mv internal/session/stats.go internal/mode/focus/stats.go +git mv internal/session/views.go internal/mode/focus/views.go +git mv internal/session/session_test.go internal/mode/focus/focus_test.go +``` + +- [ ] **Step 2: Rename package and type** + +Run: +```bash +sed -i 's/^package session$/package focus/' internal/mode/focus/*.go +sed -i 's/\*Controller/*Mode/g; s/\bController\b/Mode/g' internal/mode/focus/*.go +gofmt -w internal/mode/focus/ +``` +Manually confirm `New(snapshotPath string) (*Mode, error)` and the `Mode` struct doc comment read sensibly after the rename. + +- [ ] **Step 3: Verify the package compiles in isolation** + +Run: `go build ./internal/mode/focus/` +Expected: builds. (`store`/`statusfile`/`web`/`cmd` still reference `keel/internal/session` and will fail `go build ./...` until Tasks 12–14 — that is expected; build only the focus package here.) + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: move session -> mode/focus, Controller -> focus.Mode" +``` + +### Task 12: Make focus.Mode satisfy the mode contract + +**Files:** +- Create: `internal/mode/focus/mode.go` +- Modify: `internal/mode/focus/focus.go` (use `harness.Async`; add a terminal-state notion) + +- [ ] **Step 1: Add the contract adapter** + +```go +package focus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "keel/internal/mode/focus/domain" +) + +// Kind identifies this mode to the harness. +func (m *Mode) Kind() string { return "focus" } + +var errBadPayload = errors.New("focus: invalid command payload") + +type commitmentPayload struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` + AllowedWindowClasses []string `json:"allowed_window_classes"` + Enforce bool `json:"enforce"` +} + +// Command maps surface command names onto the existing focus transitions. +func (m *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error { + switch name { + case "planning": + return m.EnterPlanning() + case "coach": + var r struct { + Intent string `json:"intent"` + } + if err := json.Unmarshal(payload, &r); err != nil { + return errBadPayload + } + return m.RequestCoach(r.Intent) + case "commitment": + var r commitmentPayload + if err := json.Unmarshal(payload, &r); err != nil { + return errBadPayload + } + level := domain.EnforcementWarn + if r.Enforce { + level = domain.EnforcementBlock + } + return m.StartManualCommitment(r.NextAction, r.SuccessCondition, + time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level) + case "complete": + return m.Complete() + case "end": + return m.End() + case "refocus": + return m.Refocus() + case "ontask": + return m.OnTask() + default: + return fmt.Errorf("focus: unknown command %q", name) + } +} + +// View returns the focus state projection (the existing State shape). +func (m *Mode) View() any { return m.State() } + +// Active is true from Planning through Review. End() drives the runtime back to +// Locked, which the harness reads as "done" and releases. +func (m *Mode) Active() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.runtimeState != domain.RuntimeLocked +} + +// OnWindow forwards the sensor stream to the existing evidence recorder. +func (m *Mode) OnWindow(w evidence.WindowSnapshot) { m.RecordWindow(w) } +``` + +> `Deadline() time.Time` (`session.go:175`) and `Expire() error` (`session.go:277`) already exist on the type and satisfy `mode.Expirer` **as-is** — that is why `Expirer.Expire` takes no context. `RecordWindow` already exists (`session.go`, fed by `evidence.Source.Watch`). So the only new methods are `Kind`, `Command`, `View`, `Active`, `OnWindow`. Add the `keel/internal/evidence` import to `mode.go`. **Do not** touch focus's existing `onChanges`/`notify`/`SetOnChange`/`AddOnChange`/`Set*` methods — they stay and keep the moved tests valid. + +- [ ] **Step 2: Swap runFetchAsync for harness.Async** + +In `roles.go`, delete the `runFetchAsync` method (now `harness.Async.Run`). Add an `async harness.Async` field to the `Mode` struct and, in `New` (`focus.go`), initialize it wired to focus's **existing** `notify` fan-out: +```go +m.async = harness.NewAsync(&m.mu, m.notify) +``` +Then replace each `c.runFetchAsync(` call site with `c.async.Run(`; the closure bodies are unchanged. Because `m.notify` still fans out over `c.onChanges`, every test that observes via `SetOnChange`/`AddOnChange` keeps working, and the harness later registers `svc.Notify` as one of those listeners (Task 13). + +- [ ] **Step 3: Verify focus builds** + +Run: `go build ./internal/mode/focus/` +Expected: builds. + +- [ ] **Step 4: Commit** + +```bash +git add internal/mode/focus/ +git commit -m "feat: focus.Mode satisfies mode.Mode + capability interfaces" +``` + +### Task 13: Add focus Factory/Restore (constructor + setters unchanged) + +**Files:** +- Create: `internal/mode/focus/factory.go` + +The existing `New(snapshotPath string)` and the `Set*` setters (`SetClock`, +`SetCoach`, `SetTasks`, `SetKnowledge`, `SetReviewer`, `SetDriftJudge`, +`SetNudge`, `SetGuard`, `SetOnChange`/`AddOnChange`) stay **exactly as they +are** — they are the injection API the moved tests already use, so the tests +need no construction changes. The factory uses that same API. + +- [ ] **Step 1: Add the factory + restore helpers** + +```go +package focus + +import ( + "path/filepath" + + "keel/internal/harness" + "keel/internal/mode" +) + +// build constructs a focus Mode under the mode's namespaced dir and injects the +// shared services through the existing setters (the same API tests use). +func build(svc harness.Services) (*Mode, error) { + m, err := New(filepath.Join(svc.Dir, "state.json")) + if err != nil { + return nil, err + } + if svc.Clock != nil { + m.SetClock(svc.Clock) + } + m.SetCoach(svc.AI) + m.SetReviewer(svc.AI) + m.SetDriftJudge(svc.AI) + m.SetNudge(svc.AI) + m.SetTasks(svc.Tasks) + m.SetKnowledge(svc.Knowledge) + m.SetGuard(svc.Enforce) + m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener + return m, nil +} + +// Factory builds a fresh focus mode entering Planning (used by harness.Start). +func Factory(svc harness.Services) mode.Mode { + m, err := build(svc) + if err != nil { + m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works + m.SetOnChange(svc.Notify) + } + _ = m.EnterPlanning() + return m +} + +// Restore loads a persisted focus session and reports whether one was live +// (Planning/Active/Transition/Review). Used at startup to re-adopt a session. +func Restore(svc harness.Services) (*Mode, bool, error) { + m, err := build(svc) + if err != nil { + return nil, false, err + } + return m, m.Active(), nil +} +``` + +> `svc.AI` is `*ai.Service`, which implements `Coach`/`Reviewer`/`DriftJudge`/`Nudger` — pass it to each role setter. Confirm the exact setter names against `roles.go`/`drift.go` when executing; the list above matches the methods read in Phase 0. + +- [ ] **Step 2: Verify** + +Run: `go build ./internal/mode/focus/` +Expected: builds. + +- [ ] **Step 3: Commit** + +```bash +git add internal/mode/focus/factory.go +git commit -m "feat: focus Factory/Restore inject services via existing setters" +``` + +### Task 14: Rewire web to the harness + +**Files:** +- Modify: `internal/web/web.go` (server holds `*harness.Harness`; generalized routes) +- Modify: `internal/web/broadcaster.go` (unchanged mechanism; verify imports) +- Test: `internal/web/web_test.go` (retarget to harness) + +- [ ] **Step 1: Replace the controller field and routes** + +In `web.go`, change `ctrl *session.Controller` to `h *harness.Harness`. Replace the focus-specific routes with the generalized pair plus the kept SSE/settings routes: + +```go +r.GET("/events", s.handleEvents) +r.POST("/mode/:kind/start", s.handleStart) +r.POST("/mode/command/:name", s.handleCommand) +r.GET("/settings", s.handleGetSettings) +r.POST("/settings", s.handlePostSettings) +r.GET("/fs/browse", s.handleBrowse) +``` + +```go +func (s *Server) handleStart(c *gin.Context) { + kind := c.Param("kind") + err := s.h.Start(kind) + if err == nil && kind == "focus" { + s.armExpiry() + } + s.respond(c, err) +} + +func (s *Server) handleCommand(c *gin.Context) { + name := c.Param("name") + body, _ := io.ReadAll(c.Request.Body) + err := s.h.Command(c.Request.Context(), name, body) + switch name { + case "commitment": + if err == nil { + s.armExpiry() + } + case "complete", "end": + s.cancelExpiry() + } + s.respond(c, err) +} +``` + +`stateJSON` marshals `s.h.State()`. `armExpiry`/`cancelExpiry`/`Init` use `s.h.Deadline()` and `s.h.Expire(context.Background())` instead of the controller methods. `respond` maps `harness.ErrBusy`/`ErrUnknownMode`/`ErrIdle` and `statemachine.IllegalTransitionError` to 409/400 as today. + +- [ ] **Step 2: Update the web test to drive the harness** + +In `web_test.go`, construct a harness with the focus factory (or a fake mode) instead of a raw controller, and update endpoint URLs to `/mode/focus/start` and `/mode/command/commitment` etc. Keep assertions on the resulting envelope JSON (`active_mode`, `mode`). + +- [ ] **Step 3: Verify** + +Run: `go build ./internal/web/ && go test ./internal/web/` +Expected: builds; PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/web/ +git commit -m "refactor: web server drives the harness via generalized routes" +``` + +### Task 15: Rewire statusfile and main; delete the old session package + +**Files:** +- Modify: `internal/statusfile/statusfile.go` (read `mode.Envelope` instead of `session.State`) +- Modify: `cmd/keeld/main.go` (build Services, harness, register focus, restore) +- Delete: `internal/session/` (now empty after Task 11 moves) + +- [ ] **Step 1: Retarget statusfile** + +Change the writer to accept `func() mode.Envelope` and render focus's line from `env.Mode` when `env.ActiveMode == "focus"`, an idle line when `env.ActiveMode == ""`. Update `statusfile_test.go` accordingly. + +- [ ] **Step 2: Rebuild main around the harness** + +```go +func main() { + home, _ := os.UserHomeDir() + root := filepath.Join(home, ".keel") + + cfg := loadSettings() // existing settings load/seed, now under ~/.keel + backend, err := ai.NewBackend(cfg.AIBackend) + if err != nil { log.Fatalf("ai backend: %v", err) } + svc := ai.NewService(backend) + + base := harness.Services{ + AI: svc, + Tasks: tasks.NewMarvin(cfg.MarvinCmd), + Knowledge: knowledge.NewFileSource(cfg.KnowledgePath), + Enforce: enforce.NewGuard(), + Clock: time.Now, + } + h := harness.New(base, map[string]harness.Factory{ + "focus": focus.Factory, + "offscreen": offscreen.Factory, // added in Phase 3 + }) + + // Restore a live focus session, if any. + focusSvc := base + focusSvc.Dir = filepath.Join(root, "modes", "focus") + if m, live, err := focus.Restore(focusSvc); err == nil && live { + _ = h.Adopt(m) + } + + srv := web.NewServer(h) + srv.Init() + // ... statusfile writer (h.State), evidence source (h.RecordWindow), settings apply, listen ... +} +``` + +> Per-mode `Dir` is injected by the factory wrapper: register `focus.Factory` wrapped so it sets `svc.Dir = root/modes/focus` before constructing. Do the same for offscreen (`root/modes/offscreen`): +> ```go +> withDir := func(dir string, f harness.Factory) harness.Factory { +> return func(svc harness.Services) mode.Mode { svc.Dir = dir; return f(svc) } +> } +> // "focus": withDir(filepath.Join(root,"modes","focus"), focus.Factory), etc. +> ``` +> The settings `applyFn` rebuilds the AI/tasks/knowledge ports into a fresh `harness.Services` and calls `h.SetServices(...)` (added in Task 8); per its documented v1 semantics, that affects the next mode start, not the already-active mode. + +- [ ] **Step 3: Delete the empty session dir and verify the whole build** + +Run: +```bash +rmdir internal/session 2>/dev/null || git rm -r internal/session +go build ./... && go test ./... +``` +Expected: whole module builds; **all focus tests pass unchanged** (the regression oracle); harness/web/statusfile tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: wire keeld through the harness; remove session package" +``` + +### Task 16: Manual smoke test of focus parity + +- [ ] **Step 1: Run the daemon and exercise focus end to end** + +Run: `go run ./cmd/keeld` +Then in the browser at `http://localhost:7777`: start focus → plan → coach → commit (with a timebox) → observe evidence/drift → complete → review → end. Confirm behavior matches pre-refactor focus. + +- [ ] **Step 2: Confirm the runtime moved** + +Run: `ls ~/.keel/modes/focus/` +Expected: `state.json`, `audit.jsonl`, `sessions/` after a completed session. + +--- + +## Phase 3 — Off-screen mode + +A one-shot collect→propose→confirm mode. New `ai.Proposer` role, new `tasks` create effector, new `mode/offscreen` package. + +### Task 17: Add the tasks.Create effector + +**Files:** +- Modify: `internal/tasks/tasks.go` (extend `Provider`; add `NewTask`) +- Modify: `internal/tasks/marvin.go` (implement `Create`) +- Test: `internal/tasks/marvin_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) { + var gotArgs []string + m := &Marvin{run: func(ctx context.Context, args ...string) ([]byte, error) { + gotArgs = args + return []byte(`{"id":"task-1"}`), nil + }} + err := m.Create(context.Background(), tasks.NewTask("buy groceries")) + if err != nil { + t.Fatalf("Create: %v", err) + } + // add subcommand present and title passed through + joined := strings.Join(gotArgs, " ") + if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") { + t.Fatalf("args = %v, want marvin add with title", gotArgs) + } +} +``` + +> Match the test to `Marvin`'s actual runner field/shape (see `marvin.go:41-53`); the example assumes a `run` func field — adapt to the real one. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/tasks/ -run TestCreate` +Expected: FAIL (`Create`/`NewTask` undefined). + +- [ ] **Step 3: Implement** + +In `tasks.go`: +```go +type Provider interface { + Today(ctx context.Context) ([]Task, error) + Create(ctx context.Context, t Task) error +} + +// NewTask builds a minimal task for creation (title only in v1). +func NewTask(title string) Task { return Task{Title: title} } +``` +In `marvin.go`, implement `Create` by invoking the Marvin CLI's add path, preserving the `fieldUpdates` CRDT semantics the read path relies on (mirror how `Today` shells out). Return the CLI error on failure. + +- [ ] **Step 4: Verify** + +Run: `go test ./internal/tasks/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/tasks/ +git commit -m "feat: tasks.Provider.Create effector (Marvin add, CRDT-preserving)" +``` + +### Task 18: Add the ai.Proposer role + +**Files:** +- Create: `internal/ai/proposer.go` +- Test: `internal/ai/proposer_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package ai + +import ( + "context" + "testing" +) + +func TestProposeParsesProposal(t *testing.T) { + svc := NewService(fakeBackend(`{"next_action":"call mum","rationale":"people goal"}`)) + p, err := svc.Propose(context.Background(), "off-screen brief") + if err != nil { + t.Fatalf("Propose: %v", err) + } + if p.NextAction != "call mum" || p.Rationale != "people goal" { + t.Fatalf("proposal = %+v", p) + } +} +``` + +> `fakeBackend` already exists in the ai test files (used by coach/verdict tests) — reuse it; adapt the constructor name if it differs. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/ai/ -run TestPropose` +Expected: FAIL (`Propose`/`Proposal` undefined). + +- [ ] **Step 3: Implement** + +```go +package ai + +import "context" + +// OffscreenProposal is the off-screen brain output. +type OffscreenProposal struct { + NextAction string `json:"next_action"` + Rationale string `json:"rationale"` +} + +// Proposer turns an off-screen brief into a single suggested action. +type Proposer interface { + Propose(ctx context.Context, brief string) (OffscreenProposal, error) +} + +func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) { + // Mirror Service.Coach: build a prompt from brief, call s.backend, parse JSON. + // Reuse the existing JSON-extraction + ErrEmptyResponse/ErrNoJSON handling. +} +``` + +> Use the existing `Proposal` errors and JSON helpers from `proposal.go`/`coach.go`. If the name `Proposal` already exists for focus's coach output, name the off-screen type `OffscreenProposal` (as above) to avoid collision; update the test accordingly. + +- [ ] **Step 4: Verify** + +Run: `go test ./internal/ai/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ai/ +git commit -m "feat: ai.Proposer role for off-screen briefs" +``` + +### Task 19: Implement off-screen mode + +**Files:** +- Create: `internal/mode/offscreen/offscreen.go` +- Create: `internal/mode/offscreen/collect.go` (brief assembly) +- Test: `internal/mode/offscreen/offscreen_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package offscreen + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "keel/internal/ai" + "keel/internal/harness" + "keel/internal/tasks" +) + +type fakeTasks struct { + created []string +} +func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil } +func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error { + f.created = append(f.created, t.Title) + return nil +} + +func newTestMode(t *testing.T, ft *fakeTasks) *Mode { + svc := harness.Services{ + AI: ai.NewService(/* fake backend returning a proposal */), + Tasks: ft, + Clock: func() time.Time { return time.Unix(0, 0) }, + Notify: func() {}, + } + return New(svc) +} + +func TestConfirmFilesExactlyOneTask(t *testing.T) { + ft := &fakeTasks{} + m := newTestMode(t, ft) + // drive: start -> wait proposed -> confirm + _ = m.Command(context.Background(), "start", nil) + waitProposed(t, m) + if err := m.Command(context.Background(), "confirm", nil); err != nil { + t.Fatalf("confirm: %v", err) + } + if len(ft.created) != 1 { + t.Fatalf("created %d tasks, want 1", len(ft.created)) + } + if m.Active() { + t.Fatal("mode should be done after confirm") + } +} + +func TestDismissWritesNothing(t *testing.T) { + ft := &fakeTasks{} + m := newTestMode(t, ft) + _ = m.Command(context.Background(), "start", nil) + waitProposed(t, m) + _ = m.Command(context.Background(), "dismiss", nil) + if len(ft.created) != 0 { + t.Fatalf("dismiss created %d tasks, want 0", len(ft.created)) + } + if m.Active() { + t.Fatal("mode should be done after dismiss") + } +} +``` + +> `waitProposed` polls `m.View()` until status is `proposed` (the async brain call). Provide a fake backend that returns a fixed proposal JSON (reuse the ai test helper pattern). + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/mode/offscreen/` +Expected: FAIL (package undefined). + +- [ ] **Step 3: Implement the mode** + +```go +// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile +// off-screen action from the life-domain frame and files it on confirm. +package offscreen + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "keel/internal/ai" + "keel/internal/harness" + "keel/internal/mode" + "keel/internal/tasks" +) + +const proposeTimeout = 60 * time.Second + +const ( + statusPending = "pending" + statusProposed = "proposed" + statusError = "error" + statusDone = "done" +) + +type Mode struct { + mu sync.Mutex + async harness.Async + ai *ai.Service + tasks tasks.Provider + knowle knowledge.Source + dir string + clock func() time.Time + + status string + gen int + proposal *ai.OffscreenProposal + errMsg string + done bool +} + +func New(svc harness.Services) *Mode { + m := &Mode{ + ai: svc.AI, tasks: svc.Tasks, knowle: svc.Knowledge, + dir: svc.Dir, clock: svc.Clock, status: statusPending, + } + m.async = harness.NewAsync(&m.mu, svc.Notify) + return m +} + +func (m *Mode) Kind() string { return "offscreen" } + +func (m *Mode) Active() bool { + m.mu.Lock() + defer m.mu.Unlock() + return !m.done +} + +func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error { + switch name { + case "start": + return m.start() + case "confirm": + return m.confirm(ctx) + case "dismiss": + m.mu.Lock(); m.done = true; m.mu.Unlock() + return nil + default: + return errors.New("offscreen: unknown command " + name) + } +} + +func (m *Mode) start() error { + m.mu.Lock() + m.gen++ + gen := m.gen + m.status = statusPending + m.mu.Unlock() + brief := m.assembleBrief() // collect.go: tasks.Today + goals + life-bugs + var p ai.OffscreenProposal + var err error + m.async.Run(proposeTimeout, + func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) }, + func() bool { return gen != m.gen }, + func() { + if err != nil { + m.status = statusError + m.errMsg = err.Error() + return + } + m.status = statusProposed + m.proposal = &p + }) + return nil +} + +func (m *Mode) confirm(ctx context.Context) error { + m.mu.Lock() + p := m.proposal + m.mu.Unlock() + if p == nil { + return errors.New("offscreen: nothing to confirm") + } + if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil { + return err + } + m.mu.Lock() + m.status = statusDone + m.done = true + m.mu.Unlock() + return nil +} + +// View projects the proposal card. +func (m *Mode) View() any { + m.mu.Lock() + defer m.mu.Unlock() + v := map[string]any{"status": m.status} + if m.proposal != nil { + v["next_action"] = m.proposal.NextAction + v["rationale"] = m.proposal.Rationale + } + if m.errMsg != "" { + v["error"] = m.errMsg + } + return v +} + +// Factory builds a fresh off-screen mode and kicks off the brief immediately. +func Factory(svc harness.Services) mode.Mode { + m := New(svc) + _ = m.start() + return m +} +``` + +In `collect.go`, implement `assembleBrief()`: call `m.tasks.Today(ctx)` (best-effort), load `~/owc/goals-2026.md` via `m.knowle.Load`, glob the curated life-bug files (`filepath.Glob` over `~/owc/resources/bug-{physical-reset,workout-effort,nutrition-effort,missing-social-life,make-environment-beautiful,marriage-strategy}.md`), and concatenate into a budgeted string. Add the `knowledge` import to `offscreen.go`. + +- [ ] **Step 4: Verify** + +Run: `go test ./internal/mode/offscreen/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mode/offscreen/ +git commit -m "feat: off-screen mode (collect->propose->confirm)" +``` + +### Task 20: Register off-screen in main and smoke test + +**Files:** +- Modify: `cmd/keeld/main.go` (factory already referenced in Task 15; confirm the import + per-mode Dir wrapper) + +- [ ] **Step 1: Confirm registration and Dir wiring** + +Ensure `"offscreen": offscreen.Factory` is registered with its `Dir = root/modes/offscreen` wrapper, and the `offscreen` package is imported. + +- [ ] **Step 2: Verify and smoke test** + +Run: `go build ./... && go test ./...` +Expected: green. Then `go run ./cmd/keeld`, and via curl/browser: `POST /mode/offscreen/start`, observe a `proposed` envelope, `POST /mode/command/confirm`, confirm a Marvin task is filed and the harness returns to idle. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat: register off-screen mode in keeld" +``` + +--- + +## Phase 4 — Surfaces under the envelope + +The Go side already emits `mode.Envelope`. This phase reshapes the frontend to route on `active_mode` and adds the launcher + off-screen view. + +### Task 21: Frontend mode router + launcher + +**Files:** +- Modify: `internal/web/static/app.js` +- Modify: `internal/web/static/index.html` +- Modify: `internal/web/static/app.css` + +- [ ] **Step 1: Route on active_mode** + +In `app.js`, the SSE handler now receives `{active_mode, mode}`. Branch: +- `active_mode === ""` → render the launcher (two buttons: Start focus → `POST /mode/focus/start`; Start off-screen → `POST /mode/offscreen/start`). +- `active_mode === "focus"` → render the existing focus screens, fed by `mode` (the former top-level `State`). Re-point every existing `fetch('/planning'|'/coach'|'/commitment'|'/complete'|'/end'|'/refocus'|'/ontask')` call to `POST /mode/command/` (and the planning entry to `POST /mode/focus/start`). +- `active_mode === "offscreen"` → render the off-screen card (Task 22). + +- [ ] **Step 2: Manual verify focus parity through the new router** + +Run: `go run ./cmd/keeld`, exercise the full focus flow from the launcher. Confirm identical behavior. + +- [ ] **Step 3: Commit** + +```bash +git add internal/web/static/ +git commit -m "feat: frontend routes on active_mode; adds mode launcher" +``` + +### Task 22: Off-screen proposal card + +**Files:** +- Modify: `internal/web/static/app.js`, `index.html`, `app.css` + +- [ ] **Step 1: Render the card** + +When `active_mode === "offscreen"`, render from `mode`: `status === "pending"` → a spinner; `"proposed"` → the `next_action` + `rationale` with **Do it** (`POST /mode/command/confirm`) and **Dismiss** (`POST /mode/command/dismiss`); `"error"` → the error with a retry (`POST /mode/offscreen/start`). Phone-first layout (single column, large tap targets). + +- [ ] **Step 2: Manual verify on a phone-width viewport** + +Run the daemon; in devtools responsive mode, start off-screen, confirm the card renders and **Do it** files a task and returns to the launcher. + +- [ ] **Step 3: Commit** + +```bash +git add internal/web/static/ +git commit -m "feat: off-screen proposal card (phone-first)" +``` + +### Task 23: Status bar under the envelope + +**Files:** +- Modify: `internal/statusfile/statusfile.go` (already retargeted in Task 15 — finalize the off-screen line) +- Test: `internal/statusfile/statusfile_test.go` + +- [ ] **Step 1: Render one line per active mode** + +`active_mode === "focus"` → today's drift/status line (unchanged content). `"offscreen"` → e.g. `off-screen: ` when proposed, else `off-screen: thinking…`. `""` → `idle`. Add/adjust a test per branch. + +- [ ] **Step 2: Verify** + +Run: `go test ./internal/statusfile/` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add internal/statusfile/ +git commit -m "feat: status bar renders per active mode" +``` + +### Task 24: Final docs + external-config note + +**Files:** +- Modify: `docs/keel-architecture.md` §5, §10 (off-screen mode with real collectors; drop the phantom "house mode") +- Modify: `README.md` (mention the two modes + launcher) + +- [ ] **Step 1: Fix the architecture doc and README** + +Rewrite §5 ("One loop, concrete") and §10 ("Smallest real slice") around off-screen mode using the real collectors (`goals-2026.md` + life-domain `bug-*.md` + Marvin), removing the `house-integrity` goal and the solved house bug. Note off-screen memory (AW buckets) as the next increment. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "docs: describe off-screen mode; retire the house-mode phantom" +``` + +- [ ] **Step 3: External config (manual, outside the repo)** + +Felix updates by hand and confirms: +- WM status-bar config: read `~/.keel_status` (was `~/.antidrift_status`). +- Any systemd unit / shell alias / launcher: invoke `keeld` (was `antidriftd`). + +--- + +## Self-review notes + +- **Spec coverage:** Mode contract (T5), harness + Services + Async (T6–T9), focus extraction + Locked→idle + Expirer/EvidenceConsumer (T10–T16), off-screen collect→propose→gated-confirm (T17–T20), envelope surfacing + launcher + status bar (T21–T23), rename in-repo + external note (T1–T4, T24), clean-slate persistence via per-mode `Dir` (T7, T15). Non-goals (AW memory, concurrent modes) are not implemented, as intended. +- **Regression oracle:** the focus tests (`session_test.go`, internal `package session` → `package focus`) keep using `New(path)` + the `Set*` setters, both preserved unchanged (T11–T13); they must pass after rewiring (T15) — the proof the extraction preserved behavior. The `Expirer` interface deliberately takes no context so focus's existing `Expire() error` satisfies it untouched. +- **Known adaptation points flagged inline (resolve against real code when executing):** the Marvin CLI runner field shape (T17), reuse of the existing ai JSON-extraction/error helpers in `Propose` (T18), and reconstructing the web test around a harness + focus factory (T14). diff --git a/docs/superpowers/specs/2026-06-04-controller-refactor-design.md b/docs/superpowers/specs/2026-06-04-controller-refactor-design.md new file mode 100644 index 0000000..0ec51a5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-controller-refactor-design.md @@ -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//) + 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. diff --git a/go.mod b/go.mod index 57984ef..e138502 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module antidrift +module keel go 1.26.3 diff --git a/internal/ai/backend.go b/internal/ai/backend.go index 46b41a5..3732e47 100644 --- a/internal/ai/backend.go +++ b/internal/ai/backend.go @@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string { } 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 { return "", fmt.Errorf("codex: temp file: %w", err) } diff --git a/internal/ai/proposer.go b/internal/ai/proposer.go new file mode 100644 index 0000000..3252109 --- /dev/null +++ b/internal/ai/proposer.go @@ -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": "", "rationale": ""} + +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 +} diff --git a/internal/ai/proposer_test.go b/internal/ai/proposer_test.go new file mode 100644 index 0000000..6934f4d --- /dev/null +++ b/internal/ai/proposer_test.go @@ -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) + } +} diff --git a/internal/enforce/windows.go b/internal/enforce/windows.go index 36ac3e5..2828bfb 100644 --- a/internal/enforce/windows.go +++ b/internal/enforce/windows.go @@ -5,7 +5,7 @@ package enforce import ( "context" - "antidrift/internal/winapi" + "keel/internal/winapi" ) // NewGuard returns the Windows window-minimize guard. diff --git a/internal/evidence/context.go b/internal/evidence/context.go index 96263ca..61f11f4 100644 --- a/internal/evidence/context.go +++ b/internal/evidence/context.go @@ -3,7 +3,7 @@ package evidence import ( "strings" - "antidrift/internal/domain" + "keel/internal/mode/focus/domain" ) // MatchesAllowed reports whether a window (class/title) is on-task per ctx. diff --git a/internal/evidence/context_test.go b/internal/evidence/context_test.go index 48c39ff..4978c53 100644 --- a/internal/evidence/context_test.go +++ b/internal/evidence/context_test.go @@ -3,7 +3,7 @@ package evidence import ( "testing" - "antidrift/internal/domain" + "keel/internal/mode/focus/domain" ) func TestWindowClassMatchesCaseAndTrim(t *testing.T) { diff --git a/internal/evidence/evidence_test.go b/internal/evidence/evidence_test.go index 44f103a..fedd498 100644 --- a/internal/evidence/evidence_test.go +++ b/internal/evidence/evidence_test.go @@ -8,19 +8,19 @@ import ( func TestScrubTitle(t *testing.T) { cases := []struct{ in, want string }{ - {"Plain title", "Plain title"}, // no digits-with-separator: untouched - {"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory) - {"12.5%", ""}, // percent decimal: stripped whole - {"1:23:45 remaining", "remaining"}, // clock ratio: stripped, space collapsed - {"-3.0 delta", "delta"}, // leading sign + decimal: stripped - {"Download 50.5% complete", "Download complete"}, // embedded percent decimal - {"⠸ antidrift", "antidrift"}, // braille spinner frame stripped - {"⠼ antidrift", "antidrift"}, // different frame collapses to same key + {"Plain title", "Plain title"}, // no digits-with-separator: untouched + {"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory) + {"12.5%", ""}, // percent decimal: stripped whole + {"1:23:45 remaining", "remaining"}, // clock ratio: stripped, space collapsed + {"-3.0 delta", "delta"}, // leading sign + decimal: stripped + {"Download 50.5% complete", "Download complete"}, // embedded percent decimal + {"⠸ antidrift", "antidrift"}, // braille spinner frame stripped + {"⠼ antidrift", "antidrift"}, // different frame collapses to same key {"✳ Check latest commit date", "Check latest commit date"}, // dingbat-star indicator {"⠂ Check latest commit date", "Check latest commit date"}, // same task, different frame {"✳ ⠼ done", "done"}, // multiple glyphs in one title {"C++ build", "C++ build"}, // ASCII symbols kept (not all-symbols) - {"日本語 window", "日本語 window"}, // non-ASCII letters preserved + {"日本語 window", "日本語 window"}, // non-ASCII letters preserved } for _, c := range cases { if got := ScrubTitle(c.in); got != c.want { diff --git a/internal/evidence/windows.go b/internal/evidence/windows.go index 8f597b1..4666a2b 100644 --- a/internal/evidence/windows.go +++ b/internal/evidence/windows.go @@ -6,7 +6,7 @@ import ( "context" "time" - "antidrift/internal/winapi" + "keel/internal/winapi" ) // pollInterval is how often the Windows sensor samples the foreground window. diff --git a/internal/evidence/x11.go b/internal/evidence/x11.go index cbf1ace..7f1b9e9 100644 --- a/internal/evidence/x11.go +++ b/internal/evidence/x11.go @@ -86,4 +86,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot { Health: EvidenceHealth{Available: true}, } } - diff --git a/internal/harness/async.go b/internal/harness/async.go new file mode 100644 index 0000000..6a770d0 --- /dev/null +++ b/internal/harness/async.go @@ -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() + } + }() +} diff --git a/internal/harness/async_test.go b/internal/harness/async_test.go new file mode 100644 index 0000000..f05191e --- /dev/null +++ b/internal/harness/async_test.go @@ -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 +} diff --git a/internal/harness/harness.go b/internal/harness/harness.go new file mode 100644 index 0000000..63b517e --- /dev/null +++ b/internal/harness/harness.go @@ -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 +} diff --git a/internal/harness/harness_test.go b/internal/harness/harness_test.go new file mode 100644 index 0000000..0c1d336 --- /dev/null +++ b/internal/harness/harness_test.go @@ -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") + } +} diff --git a/internal/harness/services.go b/internal/harness/services.go new file mode 100644 index 0000000..e08d4b7 --- /dev/null +++ b/internal/harness/services.go @@ -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/); +// 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() +} diff --git a/internal/knowledge/file.go b/internal/knowledge/file.go index afaf5a7..fc9d3a3 100644 --- a/internal/knowledge/file.go +++ b/internal/knowledge/file.go @@ -22,7 +22,7 @@ type FileSource struct { } // 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 { 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 } -// 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. func (s *FileSource) resolve(path string) string { p := path @@ -55,7 +55,7 @@ func (s *FileSource) resolve(path string) string { } if p == "" { if home, err := os.UserHomeDir(); err == nil { - p = filepath.Join(home, ".antidrift", "knowledge.md") + p = filepath.Join(home, ".keel", "knowledge.md") } } p = expandTilde(p) diff --git a/internal/domain/domain.go b/internal/mode/focus/domain/domain.go similarity index 100% rename from internal/domain/domain.go rename to internal/mode/focus/domain/domain.go diff --git a/internal/domain/domain_test.go b/internal/mode/focus/domain/domain_test.go similarity index 100% rename from internal/domain/domain_test.go rename to internal/mode/focus/domain/domain_test.go diff --git a/internal/session/drift.go b/internal/mode/focus/drift.go similarity index 90% rename from internal/session/drift.go rename to internal/mode/focus/drift.go index 854a899..baf3877 100644 --- a/internal/session/drift.go +++ b/internal/mode/focus/drift.go @@ -1,12 +1,12 @@ -package session +package focus import ( - "antidrift/internal/ai" - "antidrift/internal/domain" - "antidrift/internal/enforce" - "antidrift/internal/evidence" - "antidrift/internal/store" "context" + "keel/internal/ai" + "keel/internal/enforce" + "keel/internal/evidence" + "keel/internal/mode/focus/domain" + "keel/internal/store" "log" "strings" "time" @@ -31,7 +31,7 @@ const ( // SetDriftJudge injects the AI drift judge. A nil judge keeps local matching // working; unmatched windows simply stay idle. -func (c *Controller) SetDriftJudge(j ai.DriftJudge) { +func (c *Mode) SetDriftJudge(j ai.DriftJudge) { c.mu.Lock() c.judge = j 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 // enforcement; everything else behaves identically. -func (c *Controller) SetGuard(g enforce.Guard) { +func (c *Mode) SetGuard(g enforce.Guard) { c.mu.Lock() defer c.mu.Unlock() 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; // 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.nudge = n 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 // prompts, or "" if there is none. Caller holds mu. -func (c *Controller) commitmentLineLocked() string { +func (c *Mode) commitmentLineLocked() string { if c.commitment == nil { return "" } @@ -63,14 +63,14 @@ func (c *Controller) commitmentLineLocked() string { } // recentTitlesForTest returns a copy of the recent-titles ring (test-only). -func (c *Controller) recentTitlesForTest() []string { +func (c *Mode) recentTitlesForTest() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.recentTitles...) } // resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu. -func (c *Controller) resetDriftLocked() { +func (c *Mode) resetDriftLocked() { c.driftStatus = driftIdle c.driftReason = "" c.driftGen++ @@ -85,7 +85,7 @@ func (c *Controller) resetDriftLocked() { // OnTask appends the current window class to the session allowed-context, clears // drift, drops the cached verdict for the current window, and persists. The // class now matches locally and is never re-judged. -func (c *Controller) OnTask() error { +func (c *Mode) OnTask() error { c.mu.Lock() defer c.mu.Unlock() 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 // 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() defer c.mu.Unlock() if c.runtimeState != domain.RuntimeActive { @@ -126,7 +126,7 @@ func (c *Controller) Refocus() error { // RecordWindow ingests one sensor observation. It accumulates time only while // Active, always tracks the latest window for display, and fires onChange after // releasing the mutex. -func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) { +func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) { c.mu.Lock() c.latestWindow = snap 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 // 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. -func (c *Controller) enforceActionLocked() func() { +func (c *Mode) enforceActionLocked() func() { if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting { return nil } @@ -163,7 +163,7 @@ func (c *Controller) enforceActionLocked() func() { ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout) defer cancel() 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 // releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has // 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 // 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.mu.Unlock() - log.Printf("session: drift judge failed: %v", err) + log.Printf("focus: drift judge failed: %v", err) c.notify() 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 // fabricates a concern on error, and notifies only after unlocking. Caller holds // 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 { return nil } @@ -293,7 +293,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() { } if err != nil { 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 } 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 // 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) if t == "" { return @@ -328,7 +328,7 @@ func verdictKey(class, title string) string { } // 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 { c.driftStatus = driftOnTask c.driftReason = "" diff --git a/internal/mode/focus/factory.go b/internal/mode/focus/factory.go new file mode 100644 index 0000000..5807018 --- /dev/null +++ b/internal/mode/focus/factory.go @@ -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 +} diff --git a/internal/session/session.go b/internal/mode/focus/focus.go similarity index 84% rename from internal/session/session.go rename to internal/mode/focus/focus.go index 924a8eb..9448630 100644 --- a/internal/session/session.go +++ b/internal/mode/focus/focus.go @@ -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 // 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. -package session +package focus import ( "errors" @@ -12,14 +12,15 @@ import ( "sync" "time" - "antidrift/internal/ai" - "antidrift/internal/domain" - "antidrift/internal/enforce" - "antidrift/internal/evidence" - "antidrift/internal/knowledge" - "antidrift/internal/statemachine" - "antidrift/internal/store" - "antidrift/internal/tasks" + "keel/internal/ai" + "keel/internal/enforce" + "keel/internal/evidence" + "keel/internal/harness" + "keel/internal/knowledge" + "keel/internal/mode/focus/domain" + "keel/internal/mode/focus/statemachine" + "keel/internal/store" + "keel/internal/tasks" "github.com/google/uuid" ) @@ -29,13 +30,14 @@ const ( 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. -type Controller struct { +// Mode holds runtime state and the active commitment behind a mutex. +type Mode struct { mu sync.Mutex + async harness.Async runtimeState domain.RuntimeState commitment *domain.Commitment deadline time.Time @@ -90,13 +92,13 @@ type Controller struct { // New loads any persisted snapshot, prunes stale session logs, and rebuilds // 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) if err != nil { return nil, err } dir := filepath.Dir(snapshotPath) - c := &Controller{ + c := &Mode{ runtimeState: s.RuntimeState, commitment: s.Commitment, snapshotPath: snapshotPath, @@ -114,6 +116,7 @@ func New(snapshotPath string) (*Controller, error) { if c.runtimeState == "" { c.runtimeState = domain.RuntimeLocked } + c.async = harness.NewAsync(&c.mu, c.notify) _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { 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 // session. -func (c *Controller) SetClock(f func() time.Time) { +func (c *Mode) SetClock(f func() time.Time) { c.mu.Lock() c.clock = f 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 // listener is fired after a state change (focus updates, role results) with the // mutex released. Use AddOnChange to register additional listeners. -func (c *Controller) SetOnChange(f func()) { +func (c *Mode) SetOnChange(f func()) { c.mu.Lock() c.onChanges = []func(){f} c.mu.Unlock() @@ -147,13 +150,13 @@ func (c *Controller) SetOnChange(f func()) { // AddOnChange registers an additional change listener alongside any already set, // so several consumers (the web broadcaster, the status-file writer) all react // 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.onChanges = append(c.onChanges, f) c.mu.Unlock() } -func (c *Controller) notify() { +func (c *Mode) notify() { c.mu.Lock() fs := append([]func(){}, c.onChanges...) c.mu.Unlock() @@ -165,20 +168,20 @@ func (c *Controller) notify() { } // State returns the current broadcastable state. Safe for concurrent use. -func (c *Controller) State() State { +func (c *Mode) State() State { c.mu.Lock() defer c.mu.Unlock() return c.stateLocked() } // 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() defer c.mu.Unlock() return c.deadline } -func (c *Controller) persistLocked() error { +func (c *Mode) persistLocked() error { snap := store.Snapshot{ RuntimeState: c.runtimeState, Commitment: c.commitment, @@ -199,7 +202,7 @@ func (c *Controller) persistLocked() error { } // EnterPlanning moves Locked -> Planning. -func (c *Controller) EnterPlanning() error { +func (c *Mode) EnterPlanning() error { c.mu.Lock() defer c.mu.Unlock() 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. -func (c *Controller) AllowedClassesForTest() []string { +func (c *Mode) AllowedClassesForTest() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.allowedClasses...) @@ -222,7 +225,7 @@ func (c *Controller) AllowedClassesForTest() []string { // EnforcementLevelForTest exposes the active session's enforcement level. Tests // only. -func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel { +func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel { c.mu.Lock() defer c.mu.Unlock() return c.enforcementLevel @@ -231,7 +234,7 @@ func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel { // StartManualCommitment validates input, activates a new commitment, mints a // session, seeds evidence stats from the latest window, and moves Planning -> // 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() defer c.mu.Unlock() 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. -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). -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() defer c.mu.Unlock() 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, // and clears the commitment and stats. -func (c *Controller) End() error { +func (c *Mode) End() error { c.mu.Lock() defer c.mu.Unlock() 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 { // State integrity over audit completeness: the transition still // 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 @@ -328,7 +331,7 @@ func (c *Controller) End() error { return c.persistLocked() } -func (c *Controller) buildSummaryLocked() store.SessionSummary { +func (c *Mode) buildSummaryLocked() store.SessionSummary { buckets := make([]store.BucketTotal, 0, len(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())}) diff --git a/internal/session/session_test.go b/internal/mode/focus/focus_test.go similarity index 98% rename from internal/session/session_test.go rename to internal/mode/focus/focus_test.go index baa4e61..f69d28d 100644 --- a/internal/session/session_test.go +++ b/internal/mode/focus/focus_test.go @@ -1,4 +1,4 @@ -package session +package focus import ( "context" @@ -11,15 +11,15 @@ import ( "testing" "time" - "antidrift/internal/ai" - "antidrift/internal/domain" - "antidrift/internal/evidence" - "antidrift/internal/knowledge" - "antidrift/internal/store" - "antidrift/internal/tasks" + "keel/internal/ai" + "keel/internal/evidence" + "keel/internal/knowledge" + "keel/internal/mode/focus/domain" + "keel/internal/store" + "keel/internal/tasks" ) -func newTestController(t *testing.T) (*Controller, string) { +func newTestController(t *testing.T) (*Mode, string) { t.Helper() path := filepath.Join(t.TempDir(), "state.json") 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. -func waitCoachStatus(t *testing.T, c *Controller, want string) State { +func waitCoachStatus(t *testing.T, c *Mode, want string) State { t.Helper() deadline := time.Now().Add(2 * time.Second) 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 } -func waitDriftStatus(t *testing.T, c *Controller, want string) State { +func waitDriftStatus(t *testing.T, c *Mode, want string) State { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { @@ -456,12 +456,12 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State { return State{} } -func startActive(t *testing.T, c *Controller, allowed []string) { +func startActive(t *testing.T, c *Mode, allowed []string) { t.Helper() 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() if err := c.EnterPlanning(); err != nil { 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) } -func waitNudge(t *testing.T, c *Controller, want string) { +func waitNudge(t *testing.T, c *Mode, want string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { @@ -963,8 +963,10 @@ func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) { 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. -func waitTasksStatus(t *testing.T, c *Controller, want string) State { +func waitTasksStatus(t *testing.T, c *Mode, want string) State { t.Helper() deadline := time.Now().Add(2 * time.Second) 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. -func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State { +func waitKnowledgeStatus(t *testing.T, c *Mode, want string) State { t.Helper() deadline := time.Now().Add(2 * time.Second) 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. -func waitReflectionStatus(t *testing.T, c *Controller, want string) State { +func waitReflectionStatus(t *testing.T, c *Mode, want string) State { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { @@ -1303,7 +1305,7 @@ func waitReflectionStatus(t *testing.T, c *Controller, want string) State { return State{} } -func driveToReview(t *testing.T, c *Controller) { +func driveToReview(t *testing.T, c *Mode) { t.Helper() if err := c.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) diff --git a/internal/mode/focus/mode.go b/internal/mode/focus/mode.go new file mode 100644 index 0000000..60061b0 --- /dev/null +++ b/internal/mode/focus/mode.go @@ -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) } diff --git a/internal/session/roles.go b/internal/mode/focus/roles.go similarity index 83% rename from internal/session/roles.go rename to internal/mode/focus/roles.go index 52f448d..36a7208 100644 --- a/internal/session/roles.go +++ b/internal/mode/focus/roles.go @@ -1,14 +1,14 @@ -package session +package focus import ( - "antidrift/internal/ai" - "antidrift/internal/domain" - "antidrift/internal/knowledge" - "antidrift/internal/store" - "antidrift/internal/tasks" "context" "errors" "fmt" + "keel/internal/ai" + "keel/internal/knowledge" + "keel/internal/mode/focus/domain" + "keel/internal/store" + "keel/internal/tasks" "strings" "time" ) @@ -58,7 +58,7 @@ const ( // SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes // RequestCoach degrade gracefully. -func (c *Controller) SetCoach(coach ai.Coach) { +func (c *Mode) SetCoach(coach ai.Coach) { c.mu.Lock() c.coach = coach 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 // request. Caller holds mu. -func (c *Controller) resetCoachLocked() { +func (c *Mode) resetCoachLocked() { c.coachStatus = coachIdle c.coachProposal = nil c.coachErr = "" @@ -75,47 +75,16 @@ func (c *Controller) resetCoachLocked() { // SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps // the planning tasks list absent. -func (c *Controller) SetTasks(p tasks.Provider) { +func (c *Mode) SetTasks(p tasks.Provider) { c.mu.Lock() c.tasksProvider = p 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 // is set. Mirrors RequestCoach: generation-guarded, discards stale or // post-planning results, and notifies on completion. Caller holds mu. -func (c *Controller) startTasksFetchLocked() { +func (c *Mode) startTasksFetchLocked() { c.tasksList = nil if c.tasksProvider == nil { c.tasksStatus = tasksIdle @@ -127,7 +96,7 @@ func (c *Controller) startTasksFetchLocked() { provider := c.tasksProvider var list []tasks.Task var err error - c.runFetchAsync(tasksTimeout, + c.async.Run(tasksTimeout, func(ctx context.Context) { list, err = provider.Today(ctx) }, func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning }, func() { @@ -143,7 +112,7 @@ func (c *Controller) startTasksFetchLocked() { // SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source // 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.knowledgeSrc = s c.mu.Unlock() @@ -152,7 +121,7 @@ func (c *Controller) SetKnowledge(s knowledge.Source) { // SetKnowledgePath selects an explicit profile path (session-only; not // persisted). While planning, it re-loads immediately so the indicator and the // 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.knowledgePath = strings.TrimSpace(path) planning := c.runtimeState == domain.RuntimePlanning @@ -169,7 +138,7 @@ func (c *Controller) SetKnowledgePath(path string) { // Mirrors startTasksFetchLocked: generation-guarded, discards stale or // post-planning results, and notifies on completion. The loaded text is cached // in knowledgeText for the coach to read. Caller holds mu. -func (c *Controller) startKnowledgeFetchLocked() { +func (c *Mode) startKnowledgeFetchLocked() { c.knowledgeText = "" c.knowledgeChars = 0 if c.knowledgeSrc == nil { @@ -183,7 +152,7 @@ func (c *Controller) startKnowledgeFetchLocked() { path := c.knowledgePath var prof knowledge.Profile var err error - c.runFetchAsync(knowledgeTimeout, + c.async.Run(knowledgeTimeout, func(ctx context.Context) { prof, err = src.Load(ctx, path) }, func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning }, func() { @@ -210,7 +179,7 @@ func (c *Controller) startKnowledgeFetchLocked() { // SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and // 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.reviewer = r 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. // 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. -func (c *Controller) startReflectionFetchLocked() { +func (c *Mode) startReflectionFetchLocked() { c.reflectionRecap = "" c.carryForward = "" if c.reviewer == nil { @@ -245,7 +214,7 @@ func (c *Controller) startReflectionFetchLocked() { history := buildReflectionHistory(c.auditPath) var refl ai.Reflection var err error - c.runFetchAsync(reflectionTimeout, + c.async.Run(reflectionTimeout, func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) }, func() bool { return gen != c.reflectionGen }, 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 // populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still // set (End clears them, but enterReview runs before End). -func (c *Controller) buildReflectionFinishedLocked() string { +func (c *Mode) buildReflectionFinishedLocked() string { var na, sc string if c.commitment != nil { 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 // the latest carry-forward takeaway into the single free-form grounding string // the coach already accepts. Caller holds mu. -func (c *Controller) composedGroundingLocked() string { +func (c *Mode) composedGroundingLocked() string { g := c.knowledgeText if c.carryForward != "" { if g != "" { @@ -354,7 +323,7 @@ func (c *Controller) composedGroundingLocked() string { // RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if // not in planning; otherwise never a hard error (failures surface as coach // 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() if c.runtimeState != domain.RuntimePlanning { c.mu.Unlock() @@ -380,7 +349,7 @@ func (c *Controller) RequestCoach(intent string) error { var prop ai.Proposal var err error - c.runFetchAsync(coachTimeout, + c.async.Run(coachTimeout, func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) }, func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning }, func() { diff --git a/internal/statemachine/statemachine.go b/internal/mode/focus/statemachine/statemachine.go similarity index 99% rename from internal/statemachine/statemachine.go rename to internal/mode/focus/statemachine/statemachine.go index 03bf451..7820a70 100644 --- a/internal/statemachine/statemachine.go +++ b/internal/mode/focus/statemachine/statemachine.go @@ -5,7 +5,7 @@ package statemachine import ( "fmt" - "antidrift/internal/domain" + "keel/internal/mode/focus/domain" ) // RuntimeAction enumerates runtime transitions. Activate's policy acceptance diff --git a/internal/statemachine/statemachine_test.go b/internal/mode/focus/statemachine/statemachine_test.go similarity index 97% rename from internal/statemachine/statemachine_test.go rename to internal/mode/focus/statemachine/statemachine_test.go index 813eb49..f54bf27 100644 --- a/internal/statemachine/statemachine_test.go +++ b/internal/mode/focus/statemachine/statemachine_test.go @@ -3,7 +3,7 @@ package statemachine import ( "testing" - "antidrift/internal/domain" + "keel/internal/mode/focus/domain" ) func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) { diff --git a/internal/session/stats.go b/internal/mode/focus/stats.go similarity index 92% rename from internal/session/stats.go rename to internal/mode/focus/stats.go index ff285d4..d220acb 100644 --- a/internal/session/stats.go +++ b/internal/mode/focus/stats.go @@ -1,8 +1,8 @@ -package session +package focus import ( - "antidrift/internal/evidence" - "antidrift/internal/store" + "keel/internal/evidence" + "keel/internal/store" "time" ) @@ -27,7 +27,7 @@ type EvidenceStats struct { // 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 // 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 { 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 // before the state transition). idle/pending route to unclassified: honest, never // 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 switch c.driftStatus { 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. -func (c *Controller) replayStats(sessionID string) { +func (c *Mode) replayStats(sessionID string) { events, err := store.ReplaySession(c.sessionsDir, sessionID) if err != nil || len(events) == 0 { c.stats = &EvidenceStats{ diff --git a/internal/session/views.go b/internal/mode/focus/views.go similarity index 98% rename from internal/session/views.go rename to internal/mode/focus/views.go index c28e7b1..633fe09 100644 --- a/internal/session/views.go +++ b/internal/mode/focus/views.go @@ -1,7 +1,7 @@ -package session +package focus import ( - "antidrift/internal/domain" + "keel/internal/mode/focus/domain" "sort" "time" ) @@ -102,7 +102,7 @@ type State struct { Drift *DriftView `json:"drift,omitempty"` } -func (c *Controller) stateLocked() State { +func (c *Mode) stateLocked() State { st := State{RuntimeState: c.runtimeState} if c.commitment != nil { view := &CommitmentView{ diff --git a/internal/mode/mode.go b/internal/mode/mode.go new file mode 100644 index 0000000..e49766f --- /dev/null +++ b/internal/mode/mode.go @@ -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"` +} diff --git a/internal/mode/offscreen/collect.go b/internal/mode/offscreen/collect.go new file mode 100644 index 0000000..ecbae2f --- /dev/null +++ b/internal/mode/offscreen/collect.go @@ -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)" +} diff --git a/internal/mode/offscreen/offscreen.go b/internal/mode/offscreen/offscreen.go new file mode 100644 index 0000000..a537c8a --- /dev/null +++ b/internal/mode/offscreen/offscreen.go @@ -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 +} diff --git a/internal/mode/offscreen/offscreen_test.go b/internal/mode/offscreen/offscreen_test.go new file mode 100644 index 0000000..5ebaf87 --- /dev/null +++ b/internal/mode/offscreen/offscreen_test.go @@ -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") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index f49e8e6..399c775 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -1,5 +1,5 @@ // 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 // on the Settings value without pulling in adapters. package settings @@ -17,20 +17,20 @@ import ( var ErrInvalidBackend = errors.New("settings: invalid ai backend") // 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 { AIBackend string `json:"ai_backend"` MarvinCmd string `json:"marvin_cmd"` KnowledgePath string `json:"knowledge_path"` } -// DefaultPath returns ~/.antidrift/settings.json. +// DefaultPath returns ~/.keel/settings.json. func DefaultPath() (string, error) { home, err := os.UserHomeDir() if err != nil { 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 @@ -64,17 +64,17 @@ func Save(path string, s Settings) error { 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 // AI backend defaults to "claude" (matching ai.NewBackend("")). func SeedFromEnv() Settings { - backend := os.Getenv("ANTIDRIFT_AI_BACKEND") + backend := os.Getenv("KEEL_AI_BACKEND") if backend == "" { backend = "claude" } return Settings{ AIBackend: backend, - MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"), - KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"), + MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"), + KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"), } } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index b283187..1916a6e 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -29,9 +29,9 @@ func TestLoadMissingFileIsError(t *testing.T) { } func TestSeedFromEnvReadsVars(t *testing.T) { - t.Setenv("ANTIDRIFT_AI_BACKEND", "codex") - t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am") - t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md") + t.Setenv("KEEL_AI_BACKEND", "codex") + t.Setenv("KEEL_MARVIN_CMD", "uv run am") + t.Setenv("KEEL_KNOWLEDGE_FILE", "/tmp/k.md") got := SeedFromEnv() want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} if got != want { @@ -40,9 +40,9 @@ func TestSeedFromEnvReadsVars(t *testing.T) { } func TestSeedFromEnvDefaultsBackend(t *testing.T) { - t.Setenv("ANTIDRIFT_AI_BACKEND", "") - t.Setenv("ANTIDRIFT_MARVIN_CMD", "") - t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "") + t.Setenv("KEEL_AI_BACKEND", "") + t.Setenv("KEEL_MARVIN_CMD", "") + t.Setenv("KEEL_KNOWLEDGE_FILE", "") got := SeedFromEnv() if got.AIBackend != "claude" { t.Errorf("default backend = %q, want claude", got.AIBackend) diff --git a/internal/statusfile/statusfile.go b/internal/statusfile/statusfile.go index ea51122..16bd12c 100644 --- a/internal/statusfile/statusfile.go +++ b/internal/statusfile/statusfile.go @@ -1,5 +1,5 @@ -// Package statusfile mirrors the controller's runtime status into a single-line -// file (~/.antidrift_status) so an external consumer — a window-manager status +// Package statusfile mirrors the harness's runtime status into a single-line +// file (~/.keel_status) so an external consumer — a window-manager status // bar — can display it with a plain file read. package statusfile @@ -9,20 +9,22 @@ import ( "log" "os" "path/filepath" + "strings" "time" - "antidrift/internal/domain" - "antidrift/internal/session" + "keel/internal/mode" + "keel/internal/mode/focus" + "keel/internal/mode/focus/domain" ) // 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 // a status bar, so a coarse tick keeps the write rate (and disk churn) low. const interval = time.Minute -// DefaultPath resolves ~/.antidrift_status. +// DefaultPath resolves ~/.keel_status. func DefaultPath() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -31,10 +33,59 @@ func DefaultPath() (string, error) { return filepath.Join(home, statusFileName), nil } -// Render produces the single status line for the given state. It is empty for -// Locked/idle so a bar module can hide itself. Drift status strings ("drifting") +// Render produces the single status line for the harness envelope, dispatching +// 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. -func Render(st session.State, now time.Time) string { +func renderFocus(st focus.State, now time.Time) string { switch st.RuntimeState { case domain.RuntimeActive: 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 // 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. -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 { return "0m" } @@ -70,11 +121,11 @@ func remaining(st session.State, now time.Time) string { 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. type Writer struct { path string - state func() session.State + state func() mode.Envelope now func() time.Time wake chan struct{} @@ -82,13 +133,14 @@ type Writer struct { wrote bool } -// NewWriter builds a Writer for path, reading state via the given accessor. -func NewWriter(path string, state func() session.State) *Writer { +// NewWriter builds a Writer for path, reading the harness envelope via the given +// 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)} } // 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. // 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, diff --git a/internal/statusfile/statusfile_test.go b/internal/statusfile/statusfile_test.go index 38fb434..fd6698b 100644 --- a/internal/statusfile/statusfile_test.go +++ b/internal/statusfile/statusfile_test.go @@ -8,17 +8,28 @@ import ( "testing" "time" - "antidrift/internal/domain" - "antidrift/internal/session" + "keel/internal/mode" + "keel/internal/mode/focus" + "keel/internal/mode/focus/domain" ) -func activeState(deadline int64, driftStatus, nudge string) session.State { - st := session.State{ +// focusEnv wraps a focus.State in the harness envelope the writer now consumes. +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, - Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline}, + Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline}, } if driftStatus != "" || nudge != "" { - st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge} + st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge} } return st } @@ -29,25 +40,33 @@ func TestRender(t *testing.T) { cases := []struct { name string - st session.State + env mode.Envelope want string }{ - {"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""}, - {"empty runtime", session.State{}, ""}, - {"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"}, - {"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"}, - {"active ontask", activeState(in24m, "ontask", ""), "● 24m"}, - {"active no drift view", activeState(in24m, "", ""), "● 24m"}, - {"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"}, - {"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"}, - {"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"}, - {"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"}, - {"active past deadline", activeState(now.Add(-5*time.Minute).Unix(), "ontask", ""), "● 0m"}, - {"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"}, + {"idle", mode.Envelope{}, "idle"}, + {"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""}, + {"empty focus runtime", focusEnv(focus.State{}), ""}, + {"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"}, + {"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"}, + {"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"}, + {"active no drift view", focusEnv(activeState(in24m, "", "")), "● 24m"}, + {"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"}, + {"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), "● 24m ·?"}, + {"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"}, + {"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"}, + {"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 { 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) } }) @@ -57,8 +76,8 @@ func TestRender(t *testing.T) { func TestWriterWritesAndDedups(t *testing.T) { path := filepath.Join(t.TempDir(), "status") now := time.Unix(1_000_000, 0) - state := session.State{RuntimeState: domain.RuntimePlanning} - w := NewWriter(path, func() session.State { return state }) + env := focusEnv(focusState(domain.RuntimePlanning)) + w := NewWriter(path, func() mode.Envelope { return env }) w.now = func() time.Time { return now } w.write() @@ -77,7 +96,7 @@ func TestWriterWritesAndDedups(t *testing.T) { } // Changed state: rewrites. - state = session.State{RuntimeState: domain.RuntimeLocked} + env = focusEnv(focusState(domain.RuntimeLocked)) w.write() if got := readFile(t, path); got != "" { t.Errorf("changed write = %q, want empty", got) @@ -86,8 +105,8 @@ func TestWriterWritesAndDedups(t *testing.T) { func TestWriterRemovesFileOnCancel(t *testing.T) { path := filepath.Join(t.TempDir(), "status") - w := NewWriter(path, func() session.State { - return session.State{RuntimeState: domain.RuntimePlanning} + w := NewWriter(path, func() mode.Envelope { + return focusEnv(focusState(domain.RuntimePlanning)) }) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) @@ -112,7 +131,7 @@ func TestWriterWakeRendersPromptly(t *testing.T) { var mu sync.Mutex 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 } ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/store/store.go b/internal/store/store.go index 1ecce17..bcd4514 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,7 +9,7 @@ import ( "os" "path/filepath" - "antidrift/internal/domain" + "keel/internal/mode/focus/domain" ) // Snapshot is the persisted current state of the daemon. @@ -29,13 +29,13 @@ type Snapshot struct { EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"` } -// DefaultPath returns ~/.antidrift/state.json. +// DefaultPath returns ~/.keel/state.json. func DefaultPath() (string, error) { home, err := os.UserHomeDir() if err != nil { 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. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ed2b359..4cca756 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "antidrift/internal/domain" + "keel/internal/mode/focus/domain" ) func TestLoadMissingFileReturnsLocked(t *testing.T) { diff --git a/internal/tasks/marvin.go b/internal/tasks/marvin.go index caea6d1..b6c1d54 100644 --- a/internal/tasks/marvin.go +++ b/internal/tasks/marvin.go @@ -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. type Marvin struct { cmd string - args []string + base []string // configured prefix after cmd, without the "--json" sentinel + args []string // read-path argv: base + ["--json"] run runner } @@ -43,9 +44,9 @@ func NewMarvin(command string) *Marvin { if len(fields) == 0 { fields = []string{"am"} } - args := append([]string{}, fields[1:]...) - args = append(args, "--json") - return &Marvin{cmd: fields[0], args: args, run: execRunner} + base := append([]string{}, fields[1:]...) + args := append(append([]string{}, base...), "--json") + 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 @@ -58,6 +59,16 @@ func (m *Marvin) Today(ctx context.Context) ([]Task, error) { 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 { ID string `json:"id"` Title string `json:"title"` diff --git a/internal/tasks/marvin_test.go b/internal/tasks/marvin_test.go index 1124af2..e7cad16 100644 --- a/internal/tasks/marvin_test.go +++ b/internal/tasks/marvin_test.go @@ -3,6 +3,7 @@ package tasks import ( "context" "errors" + "strings" "testing" ) @@ -106,3 +107,35 @@ func TestTodayPropagatesError(t *testing.T) { 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") + } +} diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 1f52779..fdfbfc0 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -13,7 +13,11 @@ type Task struct { } // Provider answers "what should I be doing?" — the open tasks due today or -// earlier. +// earlier — and lets callers add new tasks. type Provider interface { Today(ctx context.Context) ([]Task, error) + Create(ctx context.Context, t Task) error } + +// NewTask builds a minimal task for creation (title only in v1). +func NewTask(title string) Task { return Task{Title: title} } diff --git a/internal/web/settings_handlers.go b/internal/web/settings_handlers.go index 33b889e..f3b8d66 100644 --- a/internal/web/settings_handlers.go +++ b/internal/web/settings_handlers.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "antidrift/internal/settings" + "keel/internal/settings" "github.com/gin-gonic/gin" ) @@ -81,7 +81,7 @@ type browseResponse struct { // handleBrowse lists subdirectories plus .md files under dir, so the UI can build // a file picker that returns a real server-side path. Dotfiles are NOT hidden — -// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail +// the default profile lives under ~/.keel. Localhost-only daemon; no jail // beyond OS permissions (same trust boundary as the rest of the UI). func (s *Server) handleBrowse(c *gin.Context) { dir := strings.TrimSpace(c.Query("dir")) diff --git a/internal/web/settings_handlers_test.go b/internal/web/settings_handlers_test.go index a757897..4506361 100644 --- a/internal/web/settings_handlers_test.go +++ b/internal/web/settings_handlers_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "antidrift/internal/settings" + "keel/internal/settings" ) // fakeApplier records the last settings it was asked to apply and can be told to @@ -29,7 +29,7 @@ func (f *fakeApplier) apply(s settings.Settings) error { } func TestGetSettingsReturnsCurrent(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"} fa := &fakeApplier{} s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply) @@ -51,7 +51,7 @@ func TestGetSettingsReturnsCurrent(t *testing.T) { } func TestPostSettingsValidAppliesAndSaves(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) path := filepath.Join(t.TempDir(), "settings.json") fa := &fakeApplier{} s.SetSettings(path, settings.Settings{}, fa.apply) @@ -78,7 +78,7 @@ func TestPostSettingsValidAppliesAndSaves(t *testing.T) { } func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) path := filepath.Join(t.TempDir(), "settings.json") fa := &fakeApplier{reject: true} s.SetSettings(path, settings.Settings{}, fa.apply) @@ -97,7 +97,7 @@ func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) { } func TestPostSettingsInvalidJSONIs400(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) fa := &fakeApplier{} s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply) r := s.Router() @@ -123,7 +123,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) { t.Fatal(err) } - s := newTestServer(t) + s, _ := newTestServer(t) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) r := s.Router() @@ -179,7 +179,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) { } func TestBrowseBadDirIs400(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) r := s.Router() req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5344cdf..b90ff02 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -88,6 +88,10 @@ input[type="checkbox"] { .btn-ghost { background: var(--line); color: var(--ink); } .btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; } +/* Large tap targets for the launcher and off-screen primary actions. */ +.btn-big { display: block; width: 100%; margin-top: 12px; padding: 14px 16px; font-size: 16px; } +.launcher .btn-big:first-of-type { margin-top: 4px; } + /* Drift/nudge actions live in the status band; lay them out below the text. */ .band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; } .band-actions .btn { margin-top: 0; padding: 7px 12px; } @@ -176,3 +180,13 @@ input[type="checkbox"] { font-family: ui-monospace, monospace; } .browse-list button:hover { background: var(--line); color: var(--accent); } + +/* Off-screen mode: pending spinner. */ +.offscreen-pending { text-align: center; } +.spinner { + width: 28px; height: 28px; border-radius: 50%; + border: 3px solid var(--line); border-top-color: var(--accent); + animation: spin 0.8s linear infinite; margin: 4px auto 10px; +} +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 2c298d6..36e57cf 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -2,6 +2,7 @@ const app = document.getElementById('app'); const view = document.getElementById('view'); let countdownTimer = null; let renderedState = null; // which runtime_state the DOM currently shows +let renderedMode = null; // the active_mode the DOM currently shows let dismissedNudge = null; // text of the nudge the user has dismissed function post(path, body) { @@ -86,9 +87,9 @@ function updateActiveDrift(state) { `; - document.getElementById('refocus').onclick = () => post('/refocus'); - document.getElementById('ontask').onclick = () => post('/ontask'); - document.getElementById('enddrift').onclick = () => post('/complete'); + document.getElementById('refocus').onclick = () => post('/mode/command/refocus'); + document.getElementById('ontask').onclick = () => post('/mode/command/ontask'); + document.getElementById('enddrift').onclick = () => post('/mode/command/complete'); return; } @@ -223,7 +224,7 @@ function updatePlanningReflection(refl) { el.innerHTML = `Last time: ${refl.carry_forward}`; } -function render(state) { +function renderFocus(state) { setStateAttr(state); const rs = state.runtime_state; if (rs === 'planning' && renderedState === 'planning') { @@ -248,7 +249,7 @@ function render(state) {

No active commitment.

`; - document.getElementById('plan').onclick = () => post('/planning'); + document.getElementById('plan').onclick = () => post('/mode/focus/start'); } else if (rs === 'planning') { view.innerHTML = `
Planning
@@ -276,7 +277,7 @@ function render(state) { mins = document.getElementById('mins'), start = document.getElementById('start'); const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); }; [na, sc, mins].forEach(el => el.oninput = check); - start.onclick = () => post('/commitment', { + start.onclick = () => post('/mode/command/commitment', { next_action: na.value.trim(), success_condition: sc.value.trim(), timebox_secs: Math.round(+mins.value * 60), @@ -286,7 +287,7 @@ function render(state) { }); document.getElementById('sharpen').onclick = () => { const intent = document.getElementById('intent').value.trim(); - if (intent) post('/coach', { intent }); + if (intent) post('/mode/command/coach', { intent }); }; updatePlanningCoach(state.coach); updatePlanningTasks(state.tasks); @@ -304,7 +305,7 @@ function render(state) { ${evidenceBlock(state.evidence)}
`; - document.getElementById('done').onclick = () => post('/complete'); + document.getElementById('done').onclick = () => post('/mode/command/complete'); const t = document.getElementById('t'); const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); }; tick(); @@ -322,16 +323,82 @@ function render(state) { ${reflectionBlock(state.reflection)} ${reviewSummary(state.evidence)}
`; - document.getElementById('end').onclick = () => post('/end'); + document.getElementById('end').onclick = () => post('/mode/command/end'); } else { view.textContent = rs; } } +// renderLauncher shows the idle screen with mode-start buttons. +function renderLauncher() { + app.dataset.state = 'locked'; + view.innerHTML = `
Keel
+
+

No active mode.

+ + +
`; + 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 = `
+
${esc(m.next_action || '')}
+

${esc(m.rationale || '')}

+
+
+ + +
`; + } else if (status === 'error') { + body = `
+

${esc(m.error || 'Could not propose an action.')}

+
+
+ + +
`; + } else { // pending / done + body = `
+ +

Finding a worthwhile off-screen action…

+
`; + } + view.innerHTML = `
Off-screen
${body}`; + const doBtn = document.getElementById('osDo'); + if (doBtn) doBtn.onclick = () => post('/mode/command/confirm'); + const disBtn = document.getElementById('osDismiss'); + if (disBtn) disBtn.onclick = () => post('/mode/command/dismiss'); + const retryBtn = document.getElementById('osRetry'); + if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start'); +} + +// route dispatches the state envelope to the active mode's renderer. On a mode +// switch it resets the focus in-place trackers so stale DOM never leaks across. +function route(env) { + const am = (env && env.active_mode) || ''; + if (am !== renderedMode) { + renderedMode = am; + renderedState = null; + if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } + } + if (am === '') return renderLauncher(); + if (am === 'focus') return renderFocus(env.mode || {}); + if (am === 'offscreen') return renderOffscreen(env.mode || {}); + view.textContent = am; // unknown mode: degrade visibly +} + const es = new EventSource('/events'); -es.onmessage = (e) => render(JSON.parse(e.data)); -es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; }; +es.onmessage = (e) => route(JSON.parse(e.data)); +es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; }; // ---- Settings overlay ---- function openSettings() { @@ -349,7 +416,7 @@ function openSettings() {
- +
diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 4aa64ad..b3d9777 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -3,14 +3,14 @@ -AntiDrift +Keel
-

AntiDrift

+

Keel

connecting…
diff --git a/internal/web/web.go b/internal/web/web.go index 965494a..71e4b2a 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -7,15 +7,15 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "net/http" "sync" "time" - "antidrift/internal/domain" - "antidrift/internal/session" - "antidrift/internal/settings" - "antidrift/internal/statemachine" + "keel/internal/harness" + "keel/internal/mode/focus/statemachine" + "keel/internal/settings" "github.com/gin-gonic/gin" ) @@ -23,9 +23,9 @@ import ( //go:embed static/* var staticFS embed.FS -// Server wires the session controller to HTTP and SSE. +// Server wires the harness to HTTP and SSE. type Server struct { - ctrl *session.Controller + h *harness.Harness bcast *Broadcaster mu sync.Mutex @@ -38,16 +38,20 @@ type Server struct { applyMu sync.Mutex // serializes POST /settings apply+save } -func NewServer(ctrl *session.Controller) *Server { - s := &Server{ctrl: ctrl, bcast: NewBroadcaster()} - ctrl.SetOnChange(s.broadcast) +// NewServer wires the harness to the SSE broadcaster: every harness change +// (mode start, command, async role completion, expiry) fans out the current +// state envelope to subscribers. The callback is registered additively, so the +// status-file writer can register its own listener alongside this one. +func NewServer(h *harness.Harness) *Server { + s := &Server{h: h, bcast: NewBroadcaster()} + h.AddOnChange(s.broadcast) return s } func (s *Server) Router() *gin.Engine { r := gin.New() r.Use(gin.Recovery()) - // antidriftd binds localhost only and sits behind no proxy, so trust no + // keeld binds localhost only and sits behind no proxy, so trust no // forwarded-IP headers. This also silences gin's trust-all-proxies warning. _ = r.SetTrustedProxies(nil) @@ -68,13 +72,8 @@ func (s *Server) Router() *gin.Engine { c.FileFromFS("/favicon.png", http.FS(sub)) }) r.GET("/events", s.handleEvents) - r.POST("/planning", s.handlePlanning) - r.POST("/coach", s.handleCoach) - r.POST("/commitment", s.handleCommitment) - r.POST("/complete", s.handleComplete) - r.POST("/end", s.handleEnd) - r.POST("/refocus", s.handleRefocus) - r.POST("/ontask", s.handleOnTask) + r.POST("/mode/:kind/start", s.handleStart) + r.POST("/mode/command/:name", s.handleCommand) r.GET("/settings", s.handleGetSettings) r.POST("/settings", s.handlePostSettings) r.GET("/fs/browse", s.handleBrowse) @@ -82,7 +81,7 @@ func (s *Server) Router() *gin.Engine { } func (s *Server) stateJSON() string { - data, _ := json.Marshal(s.ctrl.State()) + data, _ := json.Marshal(s.h.State()) return string(data) } @@ -90,80 +89,58 @@ func (s *Server) broadcast() { s.bcast.Publish(s.stateJSON()) } -// respond maps a controller error to an HTTP status, otherwise broadcasts and -// returns the new state. +// respond maps a harness/mode error to an HTTP status, otherwise returns the +// new state envelope. Broadcasting is handled solely by the harness onChange +// listeners registered in NewServer; callers must not broadcast here. func (s *Server) respond(c *gin.Context, err error) { if err != nil { var illegal statemachine.IllegalTransitionError - if errors.As(err, &illegal) { + switch { + case errors.As(err, &illegal): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) - return + case errors.Is(err, harness.ErrBusy): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, harness.ErrIdle): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, harness.ErrUnknownMode): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - s.broadcast() c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) } -func (s *Server) handlePlanning(c *gin.Context) { - s.cancelExpiry() - s.respond(c, s.ctrl.EnterPlanning()) -} - -type coachRequest struct { - Intent string `json:"intent"` -} - -func (s *Server) handleCoach(c *gin.Context) { - var req coachRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) - return - } - s.respond(c, s.ctrl.RequestCoach(req.Intent)) -} - -type commitmentRequest struct { - NextAction string `json:"next_action"` - SuccessCondition string `json:"success_condition"` - TimeboxSecs int64 `json:"timebox_secs"` - AllowedWindowClasses []string `json:"allowed_window_classes"` - Enforce bool `json:"enforce"` -} - -func (s *Server) handleCommitment(c *gin.Context) { - var req commitmentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) - return - } - level := domain.EnforcementWarn - if req.Enforce { - level = domain.EnforcementBlock - } - err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level) - if err == nil { +// handleStart activates a mode by kind. Starting focus arms the expiry timer in +// case the mode is restored or transitions straight into a deadline-bearing +// state; the arm is a no-op when there is no deadline yet. +func (s *Server) handleStart(c *gin.Context) { + kind := c.Param("kind") + err := s.h.Start(kind) + if err == nil && kind == "focus" { s.armExpiry() } s.respond(c, err) } -func (s *Server) handleComplete(c *gin.Context) { - s.cancelExpiry() - s.respond(c, s.ctrl.Complete()) -} - -func (s *Server) handleEnd(c *gin.Context) { - s.respond(c, s.ctrl.End()) -} - -func (s *Server) handleRefocus(c *gin.Context) { - s.respond(c, s.ctrl.Refocus()) -} - -func (s *Server) handleOnTask(c *gin.Context) { - s.respond(c, s.ctrl.OnTask()) +// handleCommand routes a surface command to the active mode and manages the +// expiry timer for the focus lifecycle: a commitment arms it, completing or +// ending cancels it. The raw body is forwarded as the command payload; modes +// decode and validate it themselves. +func (s *Server) handleCommand(c *gin.Context) { + name := c.Param("name") + body, _ := io.ReadAll(c.Request.Body) + err := s.h.Command(c.Request.Context(), name, body) + switch name { + case "commitment": + if err == nil { + s.armExpiry() + } + case "complete", "end": + s.cancelExpiry() + } + s.respond(c, err) } func (s *Server) handleEvents(c *gin.Context) { @@ -196,9 +173,12 @@ func (s *Server) handleEvents(c *gin.Context) { } } -// armExpiry schedules an Active -> Review transition at the commitment deadline. +// armExpiry schedules an Active -> Review transition at the active mode's +// deadline. A zero deadline (no deadline-bearing mode) is a no-op. On firing, +// Expire notifies harness listeners, which broadcasts the new state via the +// callback registered in NewServer. func (s *Server) armExpiry() { - deadline := s.ctrl.Deadline() + deadline := s.h.Deadline() if deadline.IsZero() { return } @@ -212,9 +192,7 @@ func (s *Server) armExpiry() { d = 0 } s.timer = time.AfterFunc(d, func() { - if err := s.ctrl.Expire(); err == nil { - s.broadcast() - } + _ = s.h.Expire() }) } @@ -227,17 +205,17 @@ func (s *Server) cancelExpiry() { } } -// Init re-arms or expires a restored Active session at startup. +// Init re-arms or expires a restored deadline-bearing session at startup. With +// no active mode the deadline is zero, so this is a no-op until a session is +// adopted. func (s *Server) Init() { - st := s.ctrl.State() - if st.RuntimeState != "active" { + deadline := s.h.Deadline() + if deadline.IsZero() { return } - if s.ctrl.Deadline().After(time.Now()) { + if deadline.After(time.Now()) { s.armExpiry() return } - if err := s.ctrl.Expire(); err == nil { - s.broadcast() - } + _ = s.h.Expire() } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index e3bbd9c..8fe012b 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -9,26 +9,46 @@ import ( "testing" "time" - "antidrift/internal/ai" - "antidrift/internal/domain" - "antidrift/internal/evidence" - "antidrift/internal/knowledge" - "antidrift/internal/session" - "antidrift/internal/settings" - "antidrift/internal/tasks" + "keel/internal/ai" + "keel/internal/evidence" + "keel/internal/harness" + "keel/internal/knowledge" + "keel/internal/mode" + "keel/internal/mode/focus" + "keel/internal/mode/focus/domain" + "keel/internal/settings" + "keel/internal/tasks" "github.com/gin-gonic/gin" ) -func newTestServer(t *testing.T) *Server { +// newTestServer builds a harness around a freshly-constructed focus mode and +// returns the server plus the mode so tests can inject per-role stubs (the old +// controller's Set* API lives on focus.Mode) and assert on the typed focus +// state. The mode is registered behind a factory that wires the harness change +// notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on +// every focus change. The mode starts Locked; tests drive it through the +// command routes (e.g. POST /mode/command/planning), mirroring the original +// suite which POSTed /planning first. +func newTestServer(t *testing.T) (*Server, *focus.Mode) { t.Helper() gin.SetMode(gin.TestMode) path := filepath.Join(t.TempDir(), "state.json") - ctrl, err := session.New(path) + m, err := focus.New(path) if err != nil { - t.Fatalf("controller: %v", err) + t.Fatalf("focus.New: %v", err) } - return NewServer(ctrl) + h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{ + "focus": func(svc harness.Services) mode.Mode { + m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener + return m + }, + }) + s := NewServer(h) + if err := h.Start("focus"); err != nil { + t.Fatalf("harness.Start(focus): %v", err) + } + return s, m } func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder { @@ -40,53 +60,73 @@ func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRec return w } +// fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for +// the real-factory parity test without reaching any model. +type fakeBackend struct{} + +func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil } +func (fakeBackend) Name() string { return "fake" } + func TestPlanningThenCommitmentReachesActive(t *testing.T) { - s := newTestServer(t) + s, m := newTestServer(t) r := s.Router() - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } - if s.ctrl.State().RuntimeState != domain.RuntimeActive { - t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState) + if m.State().RuntimeState != domain.RuntimeActive { + t.Fatalf("expected Active, got %s", m.State().RuntimeState) } } func TestCommitmentRejectsInvalidInput(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) r := s.Router() - _ = post(t, r, "/planning", "") + _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"","success_condition":"x","timebox_secs":1500}` - w := post(t, r, "/commitment", body) + w := post(t, r, "/mode/command/commitment", body) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestIllegalTransitionReturns409(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) r := s.Router() // /complete from Locked is illegal. - w := post(t, r, "/complete", "") + w := post(t, r, "/mode/command/complete", "") if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d", w.Code) } } +// TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with +// the focus state nested under "mode" and the kind under "active_mode". +func TestEnvelopeShape(t *testing.T) { + s, _ := newTestServer(t) + js := s.stateJSON() + if !strings.Contains(js, `"active_mode":"focus"`) { + t.Fatalf("payload missing active_mode: %s", js) + } + if !strings.Contains(js, `"mode"`) { + t.Fatalf("payload missing nested mode object: %s", js) + } +} + func TestActiveStatePayloadCarriesEvidence(t *testing.T) { - s := newTestServer(t) + s, m := newTestServer(t) r := s.Router() - _ = post(t, r, "/planning", "") + _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } - // A focus update should appear in the serialized state. - s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}}) + // A focus update should appear in the serialized state envelope. + m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}}) js := s.stateJSON() if !strings.Contains(js, `"evidence"`) { @@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) { } func TestLockedStateHasNullEvidence(t *testing.T) { - s := newTestServer(t) + s, m := newTestServer(t) + // End any active session to return to Locked; a freshly-started focus mode is + // already Locked (Start does not auto-plan in this harness wiring). + if m.State().RuntimeState != domain.RuntimeLocked { + t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState) + } js := s.stateJSON() if !strings.Contains(js, `"evidence":null`) { t.Fatalf("locked payload should have null evidence: %s", js) @@ -115,60 +160,60 @@ func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Prop } func TestCoachRouteHappyPath(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) + s, m := newTestServer(t) + m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) r := s.Router() - _ = post(t, r, "/planning", "") - if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { + _ = post(t, r, "/mode/command/planning", "") + if w := post(t, r, "/mode/command/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { t.Fatalf("/coach code %d body %s", w.Code, w.Body.String()) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" { + if cv := m.State().Coach; cv != nil && cv.Status == "ready" { return } time.Sleep(5 * time.Millisecond) } - t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach) + t.Fatalf("coach never reached ready: %+v", m.State().Coach) } func TestCoachRouteOutsidePlanning(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetCoach(stubCoach{}) + s, m := newTestServer(t) + m.SetCoach(stubCoach{}) r := s.Router() - if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest { + if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCoachRouteInvalidJSON(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) r := s.Router() - _ = post(t, r, "/planning", "") - if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest { + _ = post(t, r, "/mode/command/planning", "") + if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCoachRouteUnavailableDegrades(t *testing.T) { - s := newTestServer(t) // no SetCoach + s, m := newTestServer(t) // no SetCoach r := s.Router() - _ = post(t, r, "/planning", "") - if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK { + _ = post(t, r, "/mode/command/planning", "") + if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusOK { t.Fatalf("unavailable coach should still 200, got %d", w.Code) } - if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" { + if cv := m.State().Coach; cv == nil || cv.Status != "error" { t.Fatalf("want error status, got %+v", cv) } } func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) r := s.Router() - if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest { + if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest { t.Fatalf("refocus outside Active: want 400, got %d", w.Code) } - if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest { + if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest { t.Fatalf("ontask outside Active: want 400, got %d", w.Code) } } @@ -180,22 +225,22 @@ func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles [ } func TestActiveStatePayloadCarriesNudge(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"}) + s, m := newTestServer(t) + m.SetNudge(stubNudger{msg: "drifted to unrelated work"}) r := s.Router() - _ = post(t, r, "/planning", "") + _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } // Two distinct on-task titles trip the nudge (history >= 2; first call has no // debounce to wait on). - s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}}) - s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}}) + m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}}) + m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}}) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" { + if d := m.State().Drift; d != nil && d.Nudge != "" { break } time.Sleep(5 * time.Millisecond) @@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) { } func TestServesStaticAssets(t *testing.T) { - s := newTestServer(t) + s, _ := newTestServer(t) r := s.Router() cases := []struct { @@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) { return s.list, nil } +func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil } + func TestPlanningStatePayloadCarriesTasks(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}}) + s, m := newTestServer(t) + m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}}) r := s.Router() - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" { + if tv := m.State().Tasks; tv != nil && tv.Status == "ready" { break } time.Sleep(5 * time.Millisecond) @@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) { } func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) { - s := newTestServer(t) + s, m := newTestServer(t) r := s.Router() - _ = post(t, r, "/planning", "") + _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String()) } - if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { + if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { t.Fatalf("commitment did not carry allowed classes: %#v", got) } // Now Active: overrides succeed. - if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK { t.Fatalf("ontask while Active: want 200, got %d", w.Code) } - if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK { t.Fatalf("refocus while Active: want 200, got %d", w.Code) } } @@ -298,15 +345,15 @@ func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, e } func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}}) + s, m := newTestServer(t) + m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}}) r := s.Router() - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" { + if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" { break } time.Sleep(5 * time.Millisecond) @@ -329,20 +376,20 @@ func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai. } func TestReflectionFlowsToReviewThenPlanning(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}}) + s, m := newTestServer(t) + m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}}) r := s.Router() - _ = post(t, r, "/planning", "") + _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } - if w := post(t, r, "/complete", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK { t.Fatalf("/complete code %d", w.Code) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" { + if rv := m.State().Reflection; rv != nil && rv.Status == "ready" { break } time.Sleep(5 * time.Millisecond) @@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) { t.Fatalf("review payload missing recap: %s", js) } - // End -> Locked -> Planning: the carry-forward should surface on planning. - if w := post(t, r, "/end", ""); w.Code != http.StatusOK { + // End -> Locked: the harness releases the focus mode and goes idle. Re-start + // the mode (re-adopting the same in-memory focus, carry-forward intact) and + // re-enter Planning; the carry-forward should surface there. + if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK { t.Fatalf("/end code %d", w.Code) } - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK { + t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String()) + } + if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } js2 := s.stateJSON() @@ -372,19 +424,19 @@ func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title stri } func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) { - s := newTestServer(t) + s, m := newTestServer(t) r := s.Router() - s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) + m.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } - s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}}) + m.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}}) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { @@ -397,15 +449,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) { } func TestKnowledgePathSelectionReloads(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) + s, m := newTestServer(t) + m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(ss settings.Settings) error { - s.ctrl.SetKnowledgePath(ss.KnowledgePath) + m.SetKnowledgePath(ss.KnowledgePath) return nil }) r := s.Router() - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { + if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK { @@ -413,10 +465,60 @@ func TestKnowledgePathSelectionReloads(t *testing.T) { } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" { + if kv := m.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" { return } time.Sleep(5 * time.Millisecond) } - t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge) + t.Fatalf("path selection did not reload: %+v", m.State().Knowledge) +} + +// TestRealFocusFactoryParity drives the REAL focus.Factory through the harness +// (Start -> commitment -> complete -> end), proving the generalized routes work +// end-to-end against a focus mode the harness itself constructs from Services — +// not a pre-built mode injected by the test. focus.Factory auto-enters Planning +// on Start, so no explicit planning command is needed. +func TestRealFocusFactoryParity(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := harness.Services{ + AI: ai.NewService(fakeBackend{}), + Clock: time.Now, + Dir: t.TempDir(), + } + h := harness.New(svc, map[string]harness.Factory{"focus": focus.Factory}) + s := NewServer(h) + r := s.Router() + + if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK { + t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String()) + } + // Start auto-plans; the envelope should report focus active and Planning. + js := s.stateJSON() + if !strings.Contains(js, `"active_mode":"focus"`) { + t.Fatalf("envelope missing active_mode focus: %s", js) + } + if !strings.Contains(js, `"runtime_state":"planning"`) { + t.Fatalf("expected planning after start: %s", js) + } + + body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}` + if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { + t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) + } + if !strings.Contains(s.stateJSON(), `"runtime_state":"active"`) { + t.Fatalf("expected active after commitment: %s", s.stateJSON()) + } + if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK { + t.Fatalf("/complete code %d", w.Code) + } + if !strings.Contains(s.stateJSON(), `"runtime_state":"review"`) { + t.Fatalf("expected review after complete: %s", s.stateJSON()) + } + if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK { + t.Fatalf("/end code %d", w.Code) + } + // End drives focus to Locked; the harness releases it and goes idle. + if got := h.State().ActiveMode; got != "" { + t.Fatalf("after end ActiveMode = %q, want idle", got) + } } diff --git a/internal/winapi/class.go b/internal/winapi/class.go index ef82147..3003cfe 100644 --- a/internal/winapi/class.go +++ b/internal/winapi/class.go @@ -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 // holds the pure path logic so it builds and is tested on every platform. package winapi