Generalize the focus controller into a harness hosting swappable modes

Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.

- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
  EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
  under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
  ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
  with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
  ignored, stale antidriftd binary removed.

Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 18:00:49 -04:00
parent 258de2c14b
commit 7d69a1f320
57 changed files with 4069 additions and 627 deletions
+1 -1
View File
@@ -2,5 +2,5 @@
.superpowers/
# Go
/antidriftd
/keeld
*.test
+6 -7
View File
@@ -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
+25 -15
View File
@@ -9,27 +9,37 @@ effectors — surfaced on a web UI and the WM status bar. The brain, the storage
> **Architecture:** [`docs/keel-architecture.md`](docs/keel-architecture.md) is the
> 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/<kind>/` (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.
-142
View File
@@ -1,142 +0,0 @@
// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI
// and owns the commitment state machine.
package main
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"time"
"antidrift/internal/ai"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/statusfile"
"antidrift/internal/store"
"antidrift/internal/tasks"
"antidrift/internal/web"
)
const addr = "localhost:7777"
func main() {
path, err := store.DefaultPath()
if err != nil {
log.Fatalf("resolve snapshot path: %v", err)
}
ctrl, err := session.New(path)
if err != nil {
log.Fatalf("load session: %v", err)
}
srv := web.NewServer(ctrl)
srv.Init() // re-arm or expire a restored Active session
// Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// The knowledge source is a single file adapter whose path is driven entirely
// by SetKnowledgePath, so startup and live settings edits share one code path.
ctrl.SetKnowledge(knowledge.NewFileSource(""))
// applyFn re-wires the running daemon from a Settings value: AI backend +
// service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It
// validates the backend FIRST and mutates nothing on failure, so POST /settings
// can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
ctrl.SetCoach(svc)
ctrl.SetDriftJudge(svc)
ctrl.SetNudge(svc)
ctrl.SetReviewer(svc)
ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))
ctrl.SetKnowledgePath(s.KnowledgePath)
return nil
}
// Apply once at startup. A bad persisted backend should not be fatal: fall back
// to claude (always valid), persist the correction, and re-apply.
if err := applyFn(cfg); err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
if err := applyFn(cfg); err != nil {
log.Fatalf("settings: apply failed even with claude: %v", err)
}
}
srv.SetSettings(settingsPath, cfg, applyFn)
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, ctrl.State)
// Re-render the bar on every state change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
ctrl.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
ctrl.SetGuard(enforce.NewGuard())
log.Printf("enforce: window-minimize guard")
src := evidence.NewSource()
go src.Watch(context.Background(), ctrl.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("antidriftd listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+188
View File
@@ -0,0 +1,188 @@
// Command keeld is the Keel daemon: it serves a local web UI and drives the
// harness — the general collect→brain→act loop that hosts one mode at a time
// (focus or offscreen).
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/offscreen"
"keel/internal/settings"
"keel/internal/statusfile"
"keel/internal/tasks"
"keel/internal/web"
)
const addr = "localhost:7777"
func main() {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("resolve home dir: %v", err)
}
root := filepath.Join(home, ".keel")
// Resolve and load settings, seeding from the legacy KEEL_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// buildServices turns a Settings value into a fresh harness.Services. It
// validates the AI backend FIRST and returns an error before constructing any
// ports, so an invalid backend never half-rewires the harness. The same code
// path serves startup and live POST /settings edits.
buildServices := func(s settings.Settings) (harness.Services, error) {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
return harness.Services{
AI: svc,
Tasks: tasks.NewMarvin(s.MarvinCmd),
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
Enforce: enforce.NewGuard(),
Clock: time.Now,
}, nil
}
// Build the base services at startup. A bad persisted backend should not be
// fatal: fall back to claude (always valid), persist the correction, and retry.
base, err := buildServices(cfg)
if err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
base, err = buildServices(cfg)
if err != nil {
log.Fatalf("settings: build failed even with claude: %v", err)
}
}
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
log.Printf("enforce: window-minimize guard")
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
// mode builds its own file paths under Services.Dir.
withDir := func(dir string, f harness.Factory) harness.Factory {
return func(s harness.Services) mode.Mode {
s.Dir = dir
return f(s)
}
}
focusDir := filepath.Join(root, "modes", "focus")
offscreenDir := filepath.Join(root, "modes", "offscreen")
h := harness.New(base, map[string]harness.Factory{
"focus": withDir(focusDir, focus.Factory),
"offscreen": withDir(offscreenDir, offscreen.Factory),
})
// Re-adopt a live focus session restored from disk, if one was in flight.
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
// restored mode's async completions reach the harness's onChange listeners.
focusSvc := h.Services()
focusSvc.Dir = focusDir
if m, live, err := focus.Restore(focusSvc); err != nil {
log.Printf("focus: restore failed (continuing idle): %v", err)
} else if live {
if err := h.Adopt(m); err != nil {
log.Printf("focus: adopt restored session failed: %v", err)
} else {
log.Printf("focus: restored a live session")
}
}
srv := web.NewServer(h)
srv.Init() // re-arm or expire a restored deadline-bearing session
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
// fresh harness.Services and installs it via SetServices. Per the harness
// contract this affects the NEXT mode start; an already-active mode keeps the
// services it was built with. It validates the backend before mutating, so
// POST /settings can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
next, err := buildServices(s)
if err != nil {
return err
}
h.SetServices(next)
return nil
}
srv.SetSettings(settingsPath, cfg, applyFn)
// Mirror runtime status to ~/.keel_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, h.State)
// Re-render the bar on every harness change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
h.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
// Feed the window sensor stream into the harness, which forwards it to the
// active mode iff that mode consumes evidence. On a headless box the source is
// a no-op, so this degrades gracefully without new X11 requirements.
src := evidence.NewSource()
go src.Watch(context.Background(), h.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("keeld listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+56 -41
View File
@@ -18,12 +18,11 @@ status: authoritative
> **Name & home.** *Keel* is the system: a human-harness that helps Felix hold
> 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/<kind>/`.
---
## 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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
# Controller Refactor — Harness + Modes — Design
**Date:** 2026-06-04
**Status:** Approved, ready for implementation planning
**Scope:** Rewrite the focus-only session controller into a generic
**harness** that hosts swappable **modes**. Focus becomes the first mode
(behavior-preserving extraction); **off-screen mode** becomes the second
(new, the proof the abstraction generalizes). The `antidrift``keel` code
rename and `~/.antidrift/``~/.keel/` runtime move land in the same pass,
clean-slate (no state migration).
## Problem
`session.Controller` (≈1,400 LOC across `session.go`, `roles.go`, `drift.go`,
`stats.go`, `views.go`) is a god-object. It conflates three things:
1. **Generic runtime orchestration** — holding the ports, the async
brain-call primitive (`runFetchAsync`), persistence, change-notify, the
evidence ingestion callback, the clock.
2. **The focus state machine**`locked→planning→active→transition→review`,
commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence
stats, reflection, session sealing.
3. **The focus domain payload**`domain.Commitment`
(`next_action / success_condition / timebox`), sitting in a package named
`domain` as if it were generic.
Focus mode is not *a* mode — it **is** the controller. There is no seam to add
a second mode. Keel's thesis (the harness is the product; focus is one mode
among many) cannot be expressed until (1) is separated from (2)+(3).
The directory and git repo are already `keel`; only the code identity still
says `antidrift`. AntiDrift was never in production and its `~/.antidrift/`
ledger is disposable, so the rename and runtime move can be done outright with
no migration ceremony.
## Goals
- A generic `internal/harness` host that owns shared services and exactly one
active mode, and routes I/O to it.
- A small `Mode` contract that holds both a long-lived stateful mode (focus)
and a one-shot request/response mode (off-screen) without crippling either.
- Focus re-homed as `mode/focus` with **identical behavior** — existing focus
tests are the regression oracle.
- Off-screen mode built end-to-end: collect → assemble → brain → surface →
gated Marvin write.
- The `antidrift``keel` rename complete in code and runtime, clean-slate.
## Non-goals (YAGNI)
- **AW-bucket memory** (the §7 "remember across runs" layer). Off-screen v1 is
stateless. AW memory is the immediate fast-follow, its own spec.
- **Concurrent / ambient modes.** Exactly one active mode at a time
(idle ↔ focus *or* off-screen). Matches today's single-commitment reality.
- **Any third mode.**
- **State migration** from `~/.antidrift/`. Clean slate by decision.
- Renaming the off-screen mode's working code name (`offscreen`) — placeholder,
rename freely later.
## Decisions that shaped this design
- **One thin `Mode` interface + capability interfaces**, not a stateless
collect/assemble/effector pipeline framework (Procrustean for focus's
stateful machine, and a YAGNI framework for two modes), and not two formal
mode *kinds* (fragments the concept before the seams are felt). Capability
interfaces (`EvidenceConsumer`, `Expirer`) give the heterogeneity honesty
without the taxonomy. If a third one-shot mode appears, off-screen's flow can
be promoted into a real pipeline helper then — driven by evidence.
- **Generalize the web routes** (`/mode/{kind}/start`, `/mode/command/{name}`)
rather than keep focus's bespoke endpoints. The frontend is rewritten under
the state envelope regardless.
- **`Locked` collapses into harness idle.** "No active mode" is the resting
state; focus is `Planning→Active→Review→done`. One fewer state.
- **Off-screen reuses the existing single-file `knowledge.Source`** for the
`~/owc/goals-2026.md` frame, plus a small in-package glob read
(`filepath.Glob` + read) for the curated life-bug set. `knowledge.Source.Load`
is single-path by contract; no new adapter package — smallest real slice.
- **The confirm click is the effector gate.** No auto-write in v1.
## Package layout
```
cmd/keeld/main.go # wires harness + modes + surfaces + settings
internal/harness/
harness.go # Harness: services, the one active mode, change-notify, store root
services.go # Services handle passed to modes
async.go # generation-guarded async primitive (was runFetchAsync)
internal/mode/
mode.go # Mode interface, capability interfaces, Envelope
internal/mode/focus/ # today's session/* + domain/* + statemachine/* re-homed
focus.go statemachine.go drift.go stats.go views.go roles.go ...
internal/mode/offscreen/
offscreen.go # NEW: off-screen mode
internal/ai/ tasks/ knowledge/ evidence/ enforce/ # shared ports — only module path changes
internal/store/ # persistence primitives + per-mode namespacing
internal/settings/ statusfile/ web/ # web becomes mode-aware
```
`internal/domain` dissolves into `mode/focus`: `Commitment`, `RuntimeState`,
`EnforcementLevel`, `AllowedContext`, `PolicySnapshot` are all focus concepts.
`internal/statemachine` likewise moves under `mode/focus` (its states are the
focus lifecycle). Off-screen touches none of them.
## The Mode contract
```go
package mode
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
Kind() string // "focus", "offscreen"
Command(ctx context.Context, name string, payload json.RawMessage) error
View() any // JSON-marshalable projection
Active() bool // false ⇒ harness returns to idle
}
// Optional capabilities — the harness asserts for these.
type EvidenceConsumer interface { OnWindow(evidence.WindowSnapshot) } // focus only
type Expirer interface { Deadline() time.Time; Expire(context.Context) error } // focus only
```
`Command` returns an error for illegal/invalid commands; the web layer maps it
to HTTP (today's `IllegalTransitionError`→409 mapping moves with focus). `View`
returns the mode's own shape, wrapped by the harness in the envelope below.
## The Harness + Services
```go
type Harness struct {
mu sync.Mutex
active mode.Mode // nil = idle
factories map[string]func(Services) mode.Mode // "focus", "offscreen"
services Services
onChange []func()
}
func (h *Harness) Start(kind string) error // idle → mode (single-active invariant)
func (h *Harness) Command(ctx, name, payload) error // routes to active; cleans up active→done
func (h *Harness) State() mode.Envelope
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) // forwards iff active is EvidenceConsumer
func (h *Harness) Deadline() time.Time // delegates iff active is Expirer
func (h *Harness) Expire(ctx) error // delegates iff active is Expirer
```
After any `Command`/`Start`, if `active.Active()` is false the harness clears
`active` to nil (idle). `Start` on a non-idle harness is an error unless the
requested kind is already active.
`Services` is what a mode receives — the ports plus relocated infrastructure:
```go
type Services struct {
AI *ai.Service // coach/judge/nudge/reviewer + new proposer role
Tasks tasks.Provider // read (Today) + new write (Create)
Knowledge knowledge.Source
Enforce enforce.Guard
Clock func() time.Time
Store *store.Scope // mode-namespaced (~/.keel/modes/<kind>/)
Notify func() // fires harness change-listeners (SSE + status bar)
}
```
### The async primitive
`runFetchAsync` (today `session/roles.go:99`) moves to `harness/async.go` as a
helper parameterized by **the mode's own mutex** plus `Services.Notify`:
```go
type Async struct { mu *sync.Mutex; notify func() }
func (a Async) Run(timeout time.Duration, fetch func(context.Context), stale func() bool, apply func())
```
This preserves today's exact lock discipline: `fetch` runs with no lock held;
`stale`/`apply` run under the mode's mutex; `notify` fires once, after unlock.
Each mode constructs one `Async{mu: &m.mu, notify: svc.Notify}`. Generation
counters and status enums stay inside each mode, exactly as today.
## Focus mode (behavior-preserving extraction)
`session.Controller` minus the host plumbing becomes `focus.Mode`. The state
machine, drift/nudge/enforcement, evidence stats, reflection, and the planning
coach/tasks/knowledge fetches move verbatim. Focus implements `Mode`,
`EvidenceConsumer` (today's `RecordWindow`), and `Expirer` (today's
deadline/`Expire`). Its `View()` returns today's `session.State` shape,
unchanged, so the existing focus screens render identically.
Command mapping (today's routes → focus `Command` names): `planning`, `coach`,
`commitment`, `complete`, `end`, `refocus`, `ontask`.
`Locked` is removed: harness idle replaces it. `Planning→Active→Review→done`,
then the harness returns to idle. `admin_override` stays inside focus (an
enforcement-escape concern). **Existing focus tests move with the package and
must pass unchanged** — they are the proof the extraction preserved behavior.
## Off-screen mode (the proof)
The away-from-the-desk complement to focus. A one-shot request/response
lifecycle — this is what proves the abstraction holds for a mode that is *not*
a long-lived session.
```
start → assembling → proposed → (confirm | dismiss) → done
```
- **`Command "start"`** — collect + assemble + brain via the async primitive;
view status `pending`.
- **Collect (real frame elements):**
- `tasks.Provider.Today()` — life / non-work items.
- `~/owc/goals-2026.md` (Body/Spirit, People, Via Negativa) via the existing
single-file `knowledge.Source`.
- `~/owc` life-domain bugs via a small in-package glob read:
`physical-reset`, `workout-effort`, `nutrition-effort`,
`missing-social-life`, `make-environment-beautiful`, `marriage-strategy`.
- **Brain:** a new thin `ai.Proposer` role —
`Propose(ctx, brief) → Proposal{NextAction, Rationale}` — reusing
`ai.Backend`. A sibling to `Coach`, not a bend of it.
- **Surface:** phone-friendly proposal card (off-screen ⇒ you're not at the
desk; the phone surface earns its keep here).
- **`Command "confirm"` (gated effector):** a **new**
`tasks.Provider.Create(ctx, Task)` files the proposed task in Marvin,
preserving the `fieldUpdates` CRDT on write. The confirm click *is* the gate.
- **`Command "dismiss"`:** done, nothing written.
- **Memory:** none in v1 (AW buckets deferred).
Off-screen implements only `Mode` (no evidence stream, no timebox), so it
exercises the capability-assertion path: the harness forwards neither window
events nor an expiry timer to it.
## Surfacing
```go
package mode
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"` // active mode's View()
}
```
Web routes generalize:
- `POST /mode/{kind}/start``harness.Start`
- `POST /mode/command/{name}``harness.Command` (request body = payload)
- `GET /events` → SSE of the envelope (broadcaster mechanism unchanged)
- The server-owned expiry timer arms from `harness.Deadline()` and fires
`harness.Expire()` via the `Expirer` assertion (today's `web.armExpiry`
logic, retargeted).
`app.js` switches on `active_mode`: `""` → launcher (pick focus / off-screen);
`"focus"` → today's screens, fed by `.mode`; `"offscreen"` → the proposal card.
The status-file writer renders one line per active mode (focus's drift line,
off-screen's proposal summary, or `idle`). **This is the largest single chunk
of new work**: the focus UI is reshaped under the envelope and a small
off-screen view is added.
## Persistence (clean slate)
- Root `~/.keel/`; settings `~/.keel/settings.json`; status `~/.keel_status`.
- Per-mode namespace via `store.Scope`:
`~/.keel/modes/focus/{state.json, audit.jsonl, sessions/}`.
- Off-screen v1 persists nothing (ephemeral; restart = idle).
- **No migration** from `~/.antidrift/`.
## The rename (lands in this pass)
In-repo:
- `go.mod` module `antidrift``keel`; all import paths.
- `cmd/antidriftd``cmd/keeld`; binary `keeld`; log strings.
- `store`/`settings`/`statusfile` default paths → `~/.keel*`.
- `settings.SeedFromEnv` `ANTIDRIFT_*``KEEL_*`.
- `CLAUDE.md` naming-status section (rename now *done*).
- `keel-architecture.md`: §1/§8 (rename resolved), §5/§10 (remove the phantom
"house mode"; describe off-screen mode with the real collectors above).
External — Felix updates by hand (out of repo, listed in the plan):
- WM status-bar config reading `~/.antidrift_status``~/.keel_status`.
- Any systemd unit / shell alias / launcher invoking `antidriftd``keeld`.
## Testing
- **Focus:** existing tests relocate with the package (import paths only) and
must pass unchanged — the regression oracle for the extraction.
- **Harness:** new unit tests with a fake mode — single-active invariant,
command routing, capability assertion (window events only to
`EvidenceConsumer`s; expiry only to `Expirer`s), idle↔active transitions,
`Active()==false` cleanup to idle.
- **Off-screen:** new unit tests with fake `ai`/`tasks` — collect→propose then
`confirm` files exactly one task (CRDT-preserving); `dismiss` writes nothing;
the gate holds.
- **Web:** envelope routing test (idle / focus / off-screen shapes).
## Open question carried into the plan
Build order. Recommended: (1) rename in place (mechanical, keeps tests green),
(2) extract harness + `Mode` with focus as the only mode (tests green proves
the extraction), (3) add off-screen mode, (4) reshape the frontend under the
envelope. Each step leaves the daemon runnable.
+1 -1
View File
@@ -1,4 +1,4 @@
module antidrift
module keel
go 1.26.3
+1 -1
View File
@@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string {
}
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
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)
}
+72
View File
@@ -0,0 +1,72 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// OffscreenProposal is the brain's suggestion for one worthwhile thing to do
// away from the screen. It is deliberately distinct from Proposal (the focus
// coach's commitment shape) to avoid a name collision.
type OffscreenProposal struct {
NextAction string `json:"next_action"`
Rationale string `json:"rationale"`
}
// Proposer turns an off-screen brief (goals, life-domain notes, today's tasks)
// into a single grounded off-screen action.
type Proposer interface {
Propose(ctx context.Context, brief string) (OffscreenProposal, error)
}
// Propose makes Service satisfy Proposer over the same backend as Coach.
func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) {
out, err := s.backend.Run(ctx, buildProposePrompt(brief))
if err != nil {
return OffscreenProposal{}, err
}
return parseOffscreenProposal(out)
}
func buildProposePrompt(brief string) string {
return `You are an off-screen advisor. Read the brief below — the user's goals, life-domain notes, and today's tasks — and pick ONE worthwhile thing for them to do now, away from the screen.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"next_action": "<one worthwhile off-screen action to do now>", "rationale": "<one short sentence: which goal or value it serves>"}
Rules:
- next_action: a single concrete off-screen action, doable now.
- rationale: one short sentence naming the goal or value it serves.
## Brief
` + brief
}
// parseOffscreenProposal extracts and validates an OffscreenProposal from raw
// CLI output, mirroring parseProposal's error discipline.
func parseOffscreenProposal(s string) (OffscreenProposal, error) {
if strings.TrimSpace(s) == "" {
return OffscreenProposal{}, ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return OffscreenProposal{}, ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
var raw OffscreenProposal
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
na := strings.TrimSpace(raw.NextAction)
if na == "" {
return OffscreenProposal{}, ErrInvalidProposal
}
return OffscreenProposal{
NextAction: na,
Rationale: strings.TrimSpace(raw.Rationale),
}, nil
}
+43
View File
@@ -0,0 +1,43 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestProposeParsesProposal(t *testing.T) {
svc := NewService(&fakeBackend{out: `{"next_action":"call mum","rationale":"people goal"}`})
p, err := svc.Propose(context.Background(), "off-screen brief")
if err != nil {
t.Fatalf("Propose: %v", err)
}
if p.NextAction != "call mum" || p.Rationale != "people goal" {
t.Fatalf("proposal = %+v", p)
}
}
func TestProposeBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Propose(context.Background(), "brief"); err == nil {
t.Fatal("want backend error")
}
}
func TestProposeUnparseable(t *testing.T) {
fb := &fakeBackend{out: "I cannot help."}
if _, err := NewService(fb).Propose(context.Background(), "brief"); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err)
}
}
func TestProposePromptIncludesBrief(t *testing.T) {
fb := &fakeBackend{out: `{"next_action":"a","rationale":"b"}`}
if _, err := NewService(fb).Propose(context.Background(), "water the tomato plants"); err != nil {
t.Fatalf("Propose: %v", err)
}
if !strings.Contains(fb.gotPrompt, "water the tomato plants") {
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package enforce
import (
"context"
"antidrift/internal/winapi"
"keel/internal/winapi"
)
// NewGuard returns the Windows window-minimize guard.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -3,7 +3,7 @@ package evidence
import (
"testing"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
+9 -9
View File
@@ -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 {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"context"
"time"
"antidrift/internal/winapi"
"keel/internal/winapi"
)
// pollInterval is how often the Windows sensor samples the foreground window.
-1
View File
@@ -86,4 +86,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
Health: EvidenceHealth{Available: true},
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package harness is the generic host: it owns shared services and exactly one
// active mode, and routes evidence, expiry, and commands to it.
package harness
import (
"context"
"sync"
"time"
)
// Async runs generation-guarded background work for a mode. It preserves the
// lock discipline of the original session.runFetchAsync: fetch runs with no
// lock held; stale and apply run under the mode's mutex; notify fires once,
// after the lock is released.
type Async struct {
mu *sync.Mutex
notify func()
}
// NewAsync binds the helper to a mode's mutex and the harness change-notify.
func NewAsync(mu *sync.Mutex, notify func()) Async {
return Async{mu: mu, notify: notify}
}
// Run launches a background fetch. stale returns true to DISCARD the result
// (a newer generation superseded it). apply records a non-stale result under
// the mutex and must NOT call notify — Run owns the post-unlock notify.
func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fetch(ctx)
a.mu.Lock()
if stale() {
a.mu.Unlock()
return
}
apply()
a.mu.Unlock()
if a.notify != nil {
a.notify()
}
}()
}
+48
View File
@@ -0,0 +1,48 @@
package harness
import (
"context"
"sync"
"testing"
"time"
)
func TestAsyncRunAppliesAndNotifies(t *testing.T) {
var mu sync.Mutex
notified := make(chan struct{}, 1)
a := NewAsync(&mu, func() { notified <- struct{}{} })
var applied bool
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { return false },
func() { applied = true },
)
select {
case <-notified:
case <-time.After(time.Second):
t.Fatal("notify never fired")
}
mu.Lock()
defer mu.Unlock()
if !applied {
t.Fatal("apply did not run")
}
}
func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) {
var mu sync.Mutex
a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") })
done := make(chan struct{})
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { close(done); return true },
func() { t.Fatal("apply ran on stale result") },
)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("stale guard never evaluated")
}
time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface
}
+187
View File
@@ -0,0 +1,187 @@
package harness
import (
"context"
"encoding/json"
"errors"
"sync"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
var (
ErrBusy = errors.New("harness: another mode is active")
ErrUnknownMode = errors.New("harness: unknown mode")
ErrIdle = errors.New("harness: no active mode")
)
// Factory builds a mode from the shared services, in its initial active state.
type Factory func(Services) mode.Mode
type Harness struct {
mu sync.Mutex
active mode.Mode
factories map[string]Factory
services Services
onChange []func()
}
// New wires Services.Notify to the harness so every mode's async completion
// reaches all listeners, then stores the services for the factories to receive.
func New(services Services, factories map[string]Factory) *Harness {
h := &Harness{factories: factories}
services.Notify = h.notify
h.services = services
return h
}
// AddOnChange registers a change listener (web broadcaster, status writer).
func (h *Harness) AddOnChange(f func()) {
h.mu.Lock()
h.onChange = append(h.onChange, f)
h.mu.Unlock()
}
// SetServices swaps the services future Start calls hand to factories. In v1 it
// affects the NEXT mode start only; an already-active mode keeps the services it
// was built with (settings changes take effect on the next session).
func (h *Harness) SetServices(s Services) {
s.Notify = h.notify
h.mu.Lock()
h.services = s
h.mu.Unlock()
}
// Services returns the shared services the harness hands to factories, with
// Notify wired to the harness change fan-out. Use it to build a mode for Adopt
// (e.g. startup restoration) so the restored mode's async work reaches the
// harness's onChange listeners.
func (h *Harness) Services() Services {
h.mu.Lock()
defer h.mu.Unlock()
return h.services
}
func (h *Harness) notify() {
h.mu.Lock()
fs := append([]func(){}, h.onChange...)
h.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// Adopt installs an already-constructed mode as active (startup restoration).
// It fails if a mode is already active.
func (h *Harness) Adopt(m mode.Mode) error {
h.mu.Lock()
if h.active != nil {
h.mu.Unlock()
return ErrBusy
}
h.active = m
h.mu.Unlock()
h.notify()
return nil
}
// Start activates a mode by kind. Idempotent if that kind is already active;
// ErrBusy if a different kind is active.
func (h *Harness) Start(kind string) error {
h.mu.Lock()
if h.active != nil {
busy := h.active.Kind() != kind
h.mu.Unlock()
if busy {
return ErrBusy
}
return nil
}
f, ok := h.factories[kind]
if !ok {
h.mu.Unlock()
return ErrUnknownMode
}
h.active = f(h.services)
h.mu.Unlock()
h.notify()
return nil
}
// Command routes to the active mode, then releases it if it went inactive.
func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return ErrIdle
}
if err := m.Command(ctx, name, payload); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
func (h *Harness) releaseIfDone() {
h.mu.Lock()
if h.active != nil && !h.active.Active() {
h.active = nil
}
h.mu.Unlock()
}
// State returns the surfacing envelope.
func (h *Harness) State() mode.Envelope {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return mode.Envelope{}
}
return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()}
}
// RecordWindow forwards a window snapshot iff the active mode consumes evidence.
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if c, ok := m.(mode.EvidenceConsumer); ok {
c.OnWindow(w)
}
}
// Deadline returns the active mode's deadline iff it is an Expirer.
func (h *Harness) Deadline() time.Time {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if e, ok := m.(mode.Expirer); ok {
return e.Deadline()
}
return time.Time{}
}
// Expire fires the active mode's expiry iff it is an Expirer, then releases it
// if it went inactive.
func (h *Harness) Expire() error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
e, ok := m.(mode.Expirer)
if !ok {
return ErrIdle
}
if err := e.Expire(); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package harness
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
// fakeMode implements Mode and (optionally) the capability interfaces.
type fakeMode struct {
mu sync.Mutex
kind string
active bool
cmds []string
windows int
deadline time.Time
expired bool
evidence bool // implements EvidenceConsumer when true
expirer bool // implements Expirer when true
}
func (f *fakeMode) Kind() string { return f.kind }
func (f *fakeMode) View() any { return map[string]bool{"active": f.active} }
func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active }
func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error {
f.mu.Lock()
defer f.mu.Unlock()
f.cmds = append(f.cmds, name)
if name == "finish" {
f.active = false
}
return nil
}
type evidenceMode struct{ *fakeMode }
func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() }
type expirerMode struct{ *fakeMode }
func (e expirerMode) Deadline() time.Time { return e.deadline }
func (e expirerMode) Expire() error {
e.mu.Lock()
e.expired = true
e.active = false
e.mu.Unlock()
return nil
}
func newHarness(m mode.Mode) *Harness {
return New(Services{Clock: time.Now}, map[string]Factory{
m.Kind(): func(Services) mode.Mode { return m },
})
}
func TestStartSingleActiveInvariant(t *testing.T) {
h := New(Services{}, map[string]Factory{
"a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} },
"b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} },
})
if err := h.Start("a"); err != nil {
t.Fatalf("Start a: %v", err)
}
if err := h.Start("a"); err != nil {
t.Fatalf("re-Start a should be idempotent: %v", err)
}
if err := h.Start("b"); err != ErrBusy {
t.Fatalf("Start b while a active = %v, want ErrBusy", err)
}
if got := h.State().ActiveMode; got != "a" {
t.Fatalf("ActiveMode = %q, want a", got)
}
}
func TestCommandReleasesWhenInactive(t *testing.T) {
m := &fakeMode{kind: "a", active: true}
h := newHarness(m)
_ = h.Start("a")
if err := h.Command(context.Background(), "finish", nil); err != nil {
t.Fatalf("Command: %v", err)
}
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after finish ActiveMode = %q, want idle", got)
}
}
func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic
if plain.windows != 0 {
t.Fatalf("plain mode received %d windows, want 0", plain.windows)
}
ev := evidenceMode{&fakeMode{kind: "ev", active: true}}
h2 := newHarness(ev)
_ = h2.Start("ev")
h2.RecordWindow(evidence.WindowSnapshot{})
if ev.windows != 1 {
t.Fatalf("evidence mode received %d windows, want 1", ev.windows)
}
}
func TestExpiryOnlyForExpirers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
if err := h.Expire(); err != ErrIdle {
t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err)
}
if !h.Deadline().IsZero() {
t.Fatal("non-expirer Deadline should be zero")
}
ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}}
h2 := newHarness(ex)
_ = h2.Start("ex")
if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) {
t.Fatalf("Deadline = %v, want unix 100", got)
}
if err := h2.Expire(); err != nil {
t.Fatalf("Expire: %v", err)
}
if got := h2.State().ActiveMode; got != "" {
t.Fatalf("after Expire ActiveMode = %q, want idle", got)
}
}
func TestCommandWhileIdle(t *testing.T) {
h := New(Services{}, map[string]Factory{})
if err := h.Command(context.Background(), "x", nil); err != ErrIdle {
t.Fatalf("Command while idle = %v, want ErrIdle", err)
}
}
func TestServicesNotifyReachesOnChangeListeners(t *testing.T) {
h := New(Services{}, map[string]Factory{})
fired := make(chan struct{}, 1)
h.AddOnChange(func() { fired <- struct{}{} })
svc := h.Services()
if svc.Notify == nil {
t.Fatal("Services().Notify is nil; restore path would not propagate updates")
}
svc.Notify() // simulate a restored mode's async completion firing notify
select {
case <-fired:
case <-time.After(time.Second):
t.Fatal("Services().Notify did not reach the registered onChange listener")
}
}
+24
View File
@@ -0,0 +1,24 @@
package harness
import (
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/knowledge"
"keel/internal/tasks"
)
// Services is the shared infrastructure every mode receives from the harness.
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
// modes build their own file paths under it. Notify fires the harness change
// listeners (web SSE + status bar).
type Services struct {
AI *ai.Service
Tasks tasks.Provider
Knowledge knowledge.Source
Enforce enforce.Guard
Clock func() time.Time
Dir string
Notify func()
}
+3 -3
View File
@@ -22,7 +22,7 @@ type FileSource struct {
}
// NewFileSource builds the adapter. defaultPath is used when Load receives an
// 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)
@@ -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 = ""
+50
View File
@@ -0,0 +1,50 @@
package focus
import (
"path/filepath"
"keel/internal/harness"
"keel/internal/mode"
)
// build constructs a focus Mode under the mode's namespaced dir and injects the
// shared services through the existing setters (the same API tests use).
func build(svc harness.Services) (*Mode, error) {
m, err := New(filepath.Join(svc.Dir, "state.json"))
if err != nil {
return nil, err
}
if svc.Clock != nil {
m.SetClock(svc.Clock)
}
m.SetCoach(svc.AI)
m.SetReviewer(svc.AI)
m.SetDriftJudge(svc.AI)
m.SetNudge(svc.AI)
m.SetTasks(svc.Tasks)
m.SetKnowledge(svc.Knowledge)
m.SetGuard(svc.Enforce)
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m, nil
}
// Factory builds a fresh focus mode entering Planning (used by harness.Start).
func Factory(svc harness.Services) mode.Mode {
m, err := build(svc)
if err != nil {
m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works
m.SetOnChange(svc.Notify)
}
_ = m.EnterPlanning()
return m
}
// Restore loads a persisted focus session and reports whether one was live
// (Planning/Active/Transition/Review). Used at startup to re-adopt a session.
func Restore(svc harness.Services) (*Mode, bool, error) {
m, err := build(svc)
if err != nil {
return nil, false, err
}
return m, m.Active(), nil
}
@@ -1,8 +1,8 @@
// Package session owns the daemon's in-memory state of truth and persists a
// Package focus owns the daemon's in-memory state of truth and persists a
// snapshot on every change. Transitions go through the pure statemachine. It
// 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())})
@@ -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)
+82
View File
@@ -0,0 +1,82 @@
package focus
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"keel/internal/evidence"
"keel/internal/mode"
"keel/internal/mode/focus/domain"
)
// Compile-time assertions: focus.Mode must satisfy the harness contracts.
var _ mode.Mode = (*Mode)(nil)
var _ mode.EvidenceConsumer = (*Mode)(nil)
var _ mode.Expirer = (*Mode)(nil)
// Kind identifies this mode to the harness.
func (c *Mode) Kind() string { return "focus" }
var errBadPayload = errors.New("focus: invalid command payload")
type commitmentPayload struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
Enforce bool `json:"enforce"`
}
// Command maps surface command names onto the existing focus transitions.
func (c *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
switch name {
case "planning":
return c.EnterPlanning()
case "coach":
var r struct {
Intent string `json:"intent"`
}
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
return c.RequestCoach(r.Intent)
case "commitment":
var r commitmentPayload
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
level := domain.EnforcementWarn
if r.Enforce {
level = domain.EnforcementBlock
}
return c.StartManualCommitment(r.NextAction, r.SuccessCondition,
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
case "complete":
return c.Complete()
case "end":
return c.End()
case "refocus":
return c.Refocus()
case "ontask":
return c.OnTask()
default:
return fmt.Errorf("focus: unknown command %q", name)
}
}
// View returns the focus state projection (the existing State shape).
func (c *Mode) View() any { return c.State() }
// Active is true from Planning through Review. End() drives the runtime back to
// Locked, which the harness reads as "done" and releases.
func (c *Mode) Active() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.runtimeState != domain.RuntimeLocked
}
// OnWindow forwards the sensor stream to the existing evidence recorder.
func (c *Mode) OnWindow(w evidence.WindowSnapshot) { c.RecordWindow(w) }
@@ -1,14 +1,14 @@
package session
package focus
import (
"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() {
@@ -5,7 +5,7 @@ package statemachine
import (
"fmt"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
@@ -3,7 +3,7 @@ package statemachine
import (
"testing"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
@@ -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{
@@ -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{
+44
View File
@@ -0,0 +1,44 @@
// Package mode defines the contract the harness uses to host a single
// collect→brain→act unit. Focus and off-screen are implementations.
package mode
import (
"context"
"encoding/json"
"time"
"keel/internal/evidence"
)
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
// Kind identifies the mode ("focus", "offscreen").
Kind() string
// Command routes a surface-issued command into the mode. It returns an
// error for illegal/invalid commands; the web layer maps it to HTTP.
Command(ctx context.Context, name string, payload json.RawMessage) error
// View projects the mode's current state for surfaces (JSON-marshalable).
View() any
// Active reports whether the mode still holds the harness. When it returns
// false the harness releases the mode and goes idle.
Active() bool
}
// EvidenceConsumer is implemented by modes that read the window sensor stream.
type EvidenceConsumer interface {
OnWindow(evidence.WindowSnapshot)
}
// Expirer is implemented by modes with a server-authoritative deadline. Expire
// takes no context to match focus's existing Expire() error method, so focus
// satisfies this interface with zero changes to its tested surface.
type Expirer interface {
Deadline() time.Time
Expire() error
}
// Envelope is the surfacing wrapper: the active mode's kind plus its View().
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"`
}
+131
View File
@@ -0,0 +1,131 @@
package offscreen
import (
"context"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
// goalsPath is the standing-goals note loaded for the off-screen brief.
const goalsPath = "~/owc/goals-2026.md"
// maxBriefBytes caps the assembled brief so the propose prompt stays bounded.
const maxBriefBytes = 8 * 1024
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
const bugGlob = "bug-*.md"
// assembleBrief builds the off-screen prompt context from today's tasks, the
// standing goals note, and the life-domain notes. It is best-effort: it never
// panics and never returns an error, and it tolerates nil ports and missing
// files (degrading to a short, mostly-empty brief).
func (m *Mode) assembleBrief() string {
var b strings.Builder
b.WriteString("## Today's tasks\n")
b.WriteString(m.briefTasks())
b.WriteString("\n")
if goals := m.briefGoals(); goals != "" {
b.WriteString("## Goals\n")
b.WriteString(goals)
b.WriteString("\n\n")
}
if domains := m.briefLifeDomains(); domains != "" {
b.WriteString("## Life domains\n")
b.WriteString(domains)
b.WriteString("\n")
}
return truncateBrief(b.String(), maxBriefBytes)
}
// briefTasks lists today's task titles, or "(none)" when the port is absent,
// errors, or returns nothing.
func (m *Mode) briefTasks() string {
if m.tasks == nil {
return "(none)\n"
}
list, err := m.tasks.Today(context.Background())
if err != nil || len(list) == 0 {
return "(none)\n"
}
var b strings.Builder
for _, t := range list {
title := strings.TrimSpace(t.Title)
if title == "" {
continue
}
b.WriteString("- ")
b.WriteString(title)
b.WriteString("\n")
}
if b.Len() == 0 {
return "(none)\n"
}
return b.String()
}
// briefGoals returns the goals note text, or "" when the port is absent or the
// file is missing/empty.
func (m *Mode) briefGoals() string {
if m.know == nil {
return ""
}
p, err := m.know.Load(context.Background(), goalsPath)
if err != nil {
return ""
}
return strings.TrimSpace(p.Text)
}
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
// port (so reads honor its truncation) and concatenates their text under one
// section. Returns "" when the port is absent or no notes are readable. File
// access stays behind the port; when the port is nil it is skipped entirely.
func (m *Mode) briefLifeDomains() string {
if m.know == nil {
return ""
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
if err != nil || len(matches) == 0 {
return ""
}
var b strings.Builder
for _, path := range matches {
p, err := m.know.Load(context.Background(), path)
if err != nil {
continue
}
text := strings.TrimSpace(p.Text)
if text == "" {
continue
}
b.WriteString("### ")
b.WriteString(filepath.Base(path))
b.WriteString("\n")
b.WriteString(text)
b.WriteString("\n\n")
}
return strings.TrimRight(b.String(), "\n")
}
// truncateBrief clips s to at most max bytes with a marker, keeping the prompt
// bounded. It backs up to a rune boundary so it never splits a multibyte rune.
func truncateBrief(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)"
}
+158
View File
@@ -0,0 +1,158 @@
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
// off-screen action from the life-domain frame and files it on confirm. It is
// one-shot — after confirm or dismiss it goes inactive and the harness releases
// it.
package offscreen
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/tasks"
)
const proposeTimeout = 60 * time.Second
const (
statusPending = "pending"
statusProposed = "proposed"
statusError = "error"
statusDone = "done"
)
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
type Mode struct {
mu sync.Mutex
async harness.Async
ai *ai.Service
tasks tasks.Provider
know knowledge.Source // may be nil; assembleBrief must guard
status string
gen int
proposal *ai.OffscreenProposal
errMsg string
done bool
}
var _ mode.Mode = (*Mode)(nil)
// New builds an off-screen mode from the shared services. It does not start the
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
func New(svc harness.Services) *Mode {
m := &Mode{
ai: svc.AI,
tasks: svc.Tasks,
know: svc.Knowledge,
status: statusPending,
}
m.async = harness.NewAsync(&m.mu, svc.Notify)
return m
}
func (m *Mode) Kind() string { return "offscreen" }
// Active reports whether the mode still holds the harness. It goes false after
// confirm or dismiss.
func (m *Mode) Active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return !m.done
}
// Command routes a surface command. start (re)runs the brief; confirm files the
// proposed action and finishes; dismiss finishes without writing.
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
switch name {
case "start":
return m.start()
case "confirm":
return m.confirm(ctx)
case "dismiss":
m.mu.Lock()
m.done = true
m.mu.Unlock()
return nil
default:
return fmt.Errorf("offscreen: unknown command %q", name)
}
}
// start assembles the brief and kicks off the brain asynchronously. A new call
// bumps the generation so a stale in-flight result is discarded.
func (m *Mode) start() error {
m.mu.Lock()
m.gen++
gen := m.gen
m.status = statusPending
m.proposal = nil
m.errMsg = ""
m.mu.Unlock()
brief := m.assembleBrief()
var p ai.OffscreenProposal
var err error
m.async.Run(proposeTimeout,
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
func() bool { return gen != m.gen },
func() {
if err != nil {
m.status = statusError
m.errMsg = err.Error()
return
}
m.status = statusProposed
m.proposal = &p
})
return nil
}
// confirm files the proposed action as a task and ends the mode. It errors if
// there is nothing to confirm yet.
func (m *Mode) confirm(ctx context.Context) error {
m.mu.Lock()
p := m.proposal
m.mu.Unlock()
if p == nil {
return errors.New("offscreen: nothing to confirm")
}
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
return err
}
m.mu.Lock()
m.status = statusDone
m.done = true
m.mu.Unlock()
return nil
}
// View projects the mode's state for surfaces. Always JSON-marshalable.
func (m *Mode) View() any {
m.mu.Lock()
defer m.mu.Unlock()
v := map[string]any{"status": m.status}
if m.proposal != nil {
v["next_action"] = m.proposal.NextAction
v["rationale"] = m.proposal.Rationale
}
if m.errMsg != "" {
v["error"] = m.errMsg
}
return v
}
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
func Factory(svc harness.Services) mode.Mode {
m := New(svc)
_ = m.start()
return m
}
+140
View File
@@ -0,0 +1,140 @@
package offscreen
import (
"context"
"errors"
"testing"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/tasks"
)
// stubBackend is a local fake ai.Backend; ai's package-internal fake is not
// reachable from this package, so we define our own here.
type stubBackend struct {
out string
err error
}
func (b stubBackend) Name() string { return "stub" }
func (b stubBackend) Run(context.Context, string) (string, error) {
return b.out, b.err
}
type fakeTasks struct {
created []string
}
func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil }
func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error {
f.created = append(f.created, t.Title)
return nil
}
func newTestMode(t *testing.T, ft *fakeTasks, backend ai.Backend) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Clock: func() time.Time { return time.Unix(0, 0) },
Notify: func() {},
}
return New(svc)
}
// waitStatus polls the mode's View until status reaches want, or fails after 2s.
func waitStatus(t *testing.T, m *Mode, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
v, _ := m.View().(map[string]any)
if v != nil && v["status"] == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("status never reached %q (last view: %+v)", want, m.View())
}
func okBackend() stubBackend {
return stubBackend{out: `{"next_action":"call mum","rationale":"people goal"}`}
}
func TestConfirmFilesExactlyOneTask(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
if len(ft.created) != 1 {
t.Fatalf("created %d tasks, want 1", len(ft.created))
}
if ft.created[0] != "call mum" {
t.Fatalf("created task title = %q, want %q", ft.created[0], "call mum")
}
if m.Active() {
t.Fatal("mode should be done after confirm")
}
}
func TestDismissWritesNothing(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
if len(ft.created) != 0 {
t.Fatalf("dismiss created %d tasks, want 0", len(ft.created))
}
if m.Active() {
t.Fatal("mode should be done after dismiss")
}
}
func TestProposeErrorSurfacesInView(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, stubBackend{err: errors.New("boom")})
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusError)
v, _ := m.View().(map[string]any)
if v == nil || v["error"] == nil || v["error"] == "" {
t.Fatalf("expected error field in view, got %+v", v)
}
if !m.Active() {
t.Fatal("mode should stay active on error so the user can retry or dismiss")
}
}
func TestConfirmWithoutProposalErrors(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
// No start → no proposal.
if err := m.Command(context.Background(), "confirm", nil); err == nil {
t.Fatal("confirm without a proposal should error")
}
if len(ft.created) != 0 {
t.Fatalf("confirm without proposal created %d tasks, want 0", len(ft.created))
}
}
func TestUnknownCommandErrors(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend())
if err := m.Command(context.Background(), "bogus", nil); err == nil {
t.Fatal("unknown command should error")
}
}
+8 -8
View File
@@ -1,5 +1,5 @@
// Package settings persists the daemon's user-editable configuration as a single
// 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"),
}
}
+6 -6
View File
@@ -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)
+67 -15
View File
@@ -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,
+45 -26
View File
@@ -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())
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestLoadMissingFileReturnsLocked(t *testing.T) {
+15 -4
View File
@@ -31,7 +31,8 @@ func execRunner(ctx context.Context, name string, args ...string) ([]byte, error
// (`am --json`, no subcommand) and parses the open tasks due today or earlier.
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"`
+33
View File
@@ -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")
}
}
+5 -1
View File
@@ -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} }
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"path/filepath"
"strings"
"antidrift/internal/settings"
"keel/internal/settings"
"github.com/gin-gonic/gin"
)
@@ -81,7 +81,7 @@ type browseResponse struct {
// handleBrowse lists subdirectories plus .md files under dir, so the UI can build
// a file picker that returns a real server-side path. Dotfiles are NOT hidden —
// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail
// the default profile lives under ~/.keel. Localhost-only daemon; no jail
// beyond OS permissions (same trust boundary as the rest of the UI).
func (s *Server) handleBrowse(c *gin.Context) {
dir := strings.TrimSpace(c.Query("dir"))
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"testing"
"antidrift/internal/settings"
"keel/internal/settings"
)
// fakeApplier records the last settings it was asked to apply and can be told to
@@ -29,7 +29,7 @@ func (f *fakeApplier) apply(s settings.Settings) error {
}
func TestGetSettingsReturnsCurrent(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
@@ -51,7 +51,7 @@ func TestGetSettingsReturnsCurrent(t *testing.T) {
}
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{}
s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -78,7 +78,7 @@ func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
}
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{reject: true}
s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -97,7 +97,7 @@ func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
}
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
r := s.Router()
@@ -123,7 +123,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
t.Fatal(err)
}
s := newTestServer(t)
s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router()
@@ -179,7 +179,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
}
func TestBrowseBadDirIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
+14
View File
@@ -88,6 +88,10 @@ input[type="checkbox"] {
.btn-ghost { background: var(--line); color: var(--ink); }
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
/* Large tap targets for the launcher and off-screen primary actions. */
.btn-big { display: block; width: 100%; margin-top: 12px; padding: 14px 16px; font-size: 16px; }
.launcher .btn-big:first-of-type { margin-top: 4px; }
/* Drift/nudge actions live in the status band; lay them out below the text. */
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
@@ -176,3 +180,13 @@ input[type="checkbox"] {
font-family: ui-monospace, monospace;
}
.browse-list button:hover { background: var(--line); color: var(--accent); }
/* Off-screen mode: pending spinner. */
.offscreen-pending { text-align: center; }
.spinner {
width: 28px; height: 28px; border-radius: 50%;
border: 3px solid var(--line); border-top-color: var(--accent);
animation: spin 0.8s linear infinite; margin: 4px auto 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
+79 -12
View File
@@ -2,6 +2,7 @@ const app = document.getElementById('app');
const view = document.getElementById('view');
let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows
let renderedMode = null; // the active_mode the DOM currently shows
let dismissedNudge = null; // text of the nudge the user has dismissed
function post(path, body) {
@@ -86,9 +87,9 @@ function updateActiveDrift(state) {
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
</div>`;
document.getElementById('refocus').onclick = () => post('/refocus');
document.getElementById('ontask').onclick = () => post('/ontask');
document.getElementById('enddrift').onclick = () => post('/complete');
document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
return;
}
@@ -223,7 +224,7 @@ function updatePlanningReflection(refl) {
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
}
function render(state) {
function renderFocus(state) {
setStateAttr(state);
const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') {
@@ -248,7 +249,7 @@ function render(state) {
<p class="meta">No active commitment.</p>
<button id="plan" class="btn btn-primary">Start planning</button>
</div>`;
document.getElementById('plan').onclick = () => post('/planning');
document.getElementById('plan').onclick = () => post('/mode/focus/start');
} else if (rs === 'planning') {
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
@@ -276,7 +277,7 @@ function render(state) {
mins = document.getElementById('mins'), start = document.getElementById('start');
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
[na, sc, mins].forEach(el => el.oninput = check);
start.onclick = () => post('/commitment', {
start.onclick = () => post('/mode/command/commitment', {
next_action: na.value.trim(),
success_condition: sc.value.trim(),
timebox_secs: Math.round(+mins.value * 60),
@@ -286,7 +287,7 @@ function render(state) {
});
document.getElementById('sharpen').onclick = () => {
const intent = document.getElementById('intent').value.trim();
if (intent) post('/coach', { intent });
if (intent) post('/mode/command/coach', { intent });
};
updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks);
@@ -304,7 +305,7 @@ function render(state) {
</div>
${evidenceBlock(state.evidence)}
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
document.getElementById('done').onclick = () => post('/complete');
document.getElementById('done').onclick = () => post('/mode/command/complete');
const t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick();
@@ -322,16 +323,82 @@ function render(state) {
${reflectionBlock(state.reflection)}
${reviewSummary(state.evidence)}
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
document.getElementById('end').onclick = () => post('/end');
document.getElementById('end').onclick = () => post('/mode/command/end');
} else {
view.textContent = rs;
}
}
// renderLauncher shows the idle screen with mode-start buttons.
function renderLauncher() {
app.dataset.state = 'locked';
view.innerHTML = `<div class="band statusband"><span class="pill">Keel</span></div>
<div class="band launcher">
<p class="meta">No active mode.</p>
<button id="startFocus" class="btn btn-primary btn-big">Start focus</button>
<button id="startOffscreen" class="btn btn-ghost btn-big">Off-screen</button>
</div>`;
document.getElementById('startFocus').onclick = () => post('/mode/focus/start');
document.getElementById('startOffscreen').onclick = () => post('/mode/offscreen/start');
}
// renderOffscreen shows the off-screen mode card. Basic but functional; Task 22
// polishes the phone-first styling.
function renderOffscreen(m) {
app.dataset.state = 'review';
const status = m.status || 'pending';
let body;
if (status === 'proposed') {
body = `<div class="band">
<div class="action">${esc(m.next_action || '')}</div>
<p class="meta">${esc(m.rationale || '')}</p>
</div>
<div class="band band-actions">
<button id="osDo" class="btn btn-primary btn-big">Do it</button>
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
</div>`;
} else if (status === 'error') {
body = `<div class="band">
<p class="meta">${esc(m.error || 'Could not propose an action.')}</p>
</div>
<div class="band band-actions">
<button id="osRetry" class="btn btn-primary btn-big">Retry</button>
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
</div>`;
} else { // pending / done
body = `<div class="band offscreen-pending">
<div class="spinner" aria-hidden="true"></div>
<p class="meta">Finding a worthwhile off-screen action…</p>
</div>`;
}
view.innerHTML = `<div class="band statusband"><span class="pill">Off-screen</span></div>${body}`;
const doBtn = document.getElementById('osDo');
if (doBtn) doBtn.onclick = () => post('/mode/command/confirm');
const disBtn = document.getElementById('osDismiss');
if (disBtn) disBtn.onclick = () => post('/mode/command/dismiss');
const retryBtn = document.getElementById('osRetry');
if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start');
}
// route dispatches the state envelope to the active mode's renderer. On a mode
// switch it resets the focus in-place trackers so stale DOM never leaks across.
function route(env) {
const am = (env && env.active_mode) || '';
if (am !== renderedMode) {
renderedMode = am;
renderedState = null;
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
}
if (am === '') return renderLauncher();
if (am === 'focus') return renderFocus(env.mode || {});
if (am === 'offscreen') return renderOffscreen(env.mode || {});
view.textContent = am; // unknown mode: degrade visibly
}
const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
es.onmessage = (e) => route(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; };
// ---- Settings overlay ----
function openSettings() {
@@ -349,7 +416,7 @@ function openSettings() {
<input id="setMarvin" placeholder="uv run am">
<label>Knowledge profile path</label>
<div class="path-row">
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
<input id="setKnow" placeholder="~/.keel/knowledge.md">
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
</div>
<div id="browsePane" class="browse" hidden></div>
+2 -2
View File
@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AntiDrift</title>
<title>Keel</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<main id="app">
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings"></button></h1>
<h1>Keel <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings"></button></h1>
<div class="card" id="view">connecting…</div>
<div id="settingsOverlay" class="overlay" hidden></div>
</main>
+67 -89
View File
@@ -7,15 +7,15 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"sync"
"time"
"antidrift/internal/domain"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/statemachine"
"keel/internal/harness"
"keel/internal/mode/focus/statemachine"
"keel/internal/settings"
"github.com/gin-gonic/gin"
)
@@ -23,9 +23,9 @@ import (
//go:embed static/*
var staticFS embed.FS
// Server wires the session controller to HTTP and SSE.
// Server wires the harness to HTTP and SSE.
type Server struct {
ctrl *session.Controller
h *harness.Harness
bcast *Broadcaster
mu sync.Mutex
@@ -38,16 +38,20 @@ type Server struct {
applyMu sync.Mutex // serializes POST /settings apply+save
}
func NewServer(ctrl *session.Controller) *Server {
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()}
ctrl.SetOnChange(s.broadcast)
// NewServer wires the harness to the SSE broadcaster: every harness change
// (mode start, command, async role completion, expiry) fans out the current
// state envelope to subscribers. The callback is registered additively, so the
// status-file writer can register its own listener alongside this one.
func NewServer(h *harness.Harness) *Server {
s := &Server{h: h, bcast: NewBroadcaster()}
h.AddOnChange(s.broadcast)
return s
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// antidriftd binds localhost only and sits behind no proxy, so trust no
// keeld binds localhost only and sits behind no proxy, so trust no
// forwarded-IP headers. This also silences gin's trust-all-proxies warning.
_ = r.SetTrustedProxies(nil)
@@ -68,13 +72,8 @@ func (s *Server) Router() *gin.Engine {
c.FileFromFS("/favicon.png", http.FS(sub))
})
r.GET("/events", s.handleEvents)
r.POST("/planning", s.handlePlanning)
r.POST("/coach", s.handleCoach)
r.POST("/commitment", s.handleCommitment)
r.POST("/complete", s.handleComplete)
r.POST("/end", s.handleEnd)
r.POST("/refocus", s.handleRefocus)
r.POST("/ontask", s.handleOnTask)
r.POST("/mode/:kind/start", s.handleStart)
r.POST("/mode/command/:name", s.handleCommand)
r.GET("/settings", s.handleGetSettings)
r.POST("/settings", s.handlePostSettings)
r.GET("/fs/browse", s.handleBrowse)
@@ -82,7 +81,7 @@ func (s *Server) Router() *gin.Engine {
}
func (s *Server) stateJSON() string {
data, _ := json.Marshal(s.ctrl.State())
data, _ := json.Marshal(s.h.State())
return string(data)
}
@@ -90,80 +89,58 @@ func (s *Server) broadcast() {
s.bcast.Publish(s.stateJSON())
}
// respond maps a controller error to an HTTP status, otherwise broadcasts and
// returns the new state.
// respond maps a harness/mode error to an HTTP status, otherwise returns the
// new state envelope. Broadcasting is handled solely by the harness onChange
// listeners registered in NewServer; callers must not broadcast here.
func (s *Server) respond(c *gin.Context, err error) {
if err != nil {
var illegal statemachine.IllegalTransitionError
if errors.As(err, &illegal) {
switch {
case errors.As(err, &illegal):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
case errors.Is(err, harness.ErrBusy):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, harness.ErrIdle):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, harness.ErrUnknownMode):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s.broadcast()
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
}
func (s *Server) handlePlanning(c *gin.Context) {
s.cancelExpiry()
s.respond(c, s.ctrl.EnterPlanning())
}
type coachRequest struct {
Intent string `json:"intent"`
}
func (s *Server) handleCoach(c *gin.Context) {
var req coachRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
s.respond(c, s.ctrl.RequestCoach(req.Intent))
}
type commitmentRequest struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
Enforce bool `json:"enforce"`
}
func (s *Server) handleCommitment(c *gin.Context) {
var req commitmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
level := domain.EnforcementWarn
if req.Enforce {
level = domain.EnforcementBlock
}
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
if err == nil {
// handleStart activates a mode by kind. Starting focus arms the expiry timer in
// case the mode is restored or transitions straight into a deadline-bearing
// state; the arm is a no-op when there is no deadline yet.
func (s *Server) handleStart(c *gin.Context) {
kind := c.Param("kind")
err := s.h.Start(kind)
if err == nil && kind == "focus" {
s.armExpiry()
}
s.respond(c, err)
}
func (s *Server) handleComplete(c *gin.Context) {
s.cancelExpiry()
s.respond(c, s.ctrl.Complete())
}
func (s *Server) handleEnd(c *gin.Context) {
s.respond(c, s.ctrl.End())
}
func (s *Server) handleRefocus(c *gin.Context) {
s.respond(c, s.ctrl.Refocus())
}
func (s *Server) handleOnTask(c *gin.Context) {
s.respond(c, s.ctrl.OnTask())
// handleCommand routes a surface command to the active mode and manages the
// expiry timer for the focus lifecycle: a commitment arms it, completing or
// ending cancels it. The raw body is forwarded as the command payload; modes
// decode and validate it themselves.
func (s *Server) handleCommand(c *gin.Context) {
name := c.Param("name")
body, _ := io.ReadAll(c.Request.Body)
err := s.h.Command(c.Request.Context(), name, body)
switch name {
case "commitment":
if err == nil {
s.armExpiry()
}
case "complete", "end":
s.cancelExpiry()
}
s.respond(c, err)
}
func (s *Server) handleEvents(c *gin.Context) {
@@ -196,9 +173,12 @@ func (s *Server) handleEvents(c *gin.Context) {
}
}
// armExpiry schedules an Active -> Review transition at the commitment deadline.
// armExpiry schedules an Active -> Review transition at the active mode's
// deadline. A zero deadline (no deadline-bearing mode) is a no-op. On firing,
// Expire notifies harness listeners, which broadcasts the new state via the
// callback registered in NewServer.
func (s *Server) armExpiry() {
deadline := s.ctrl.Deadline()
deadline := s.h.Deadline()
if deadline.IsZero() {
return
}
@@ -212,9 +192,7 @@ func (s *Server) armExpiry() {
d = 0
}
s.timer = time.AfterFunc(d, func() {
if err := s.ctrl.Expire(); err == nil {
s.broadcast()
}
_ = s.h.Expire()
})
}
@@ -227,17 +205,17 @@ func (s *Server) cancelExpiry() {
}
}
// Init re-arms or expires a restored Active session at startup.
// Init re-arms or expires a restored deadline-bearing session at startup. With
// no active mode the deadline is zero, so this is a no-op until a session is
// adopted.
func (s *Server) Init() {
st := s.ctrl.State()
if st.RuntimeState != "active" {
deadline := s.h.Deadline()
if deadline.IsZero() {
return
}
if s.ctrl.Deadline().After(time.Now()) {
if deadline.After(time.Now()) {
s.armExpiry()
return
}
if err := s.ctrl.Expire(); err == nil {
s.broadcast()
}
_ = s.h.Expire()
}
+190 -88
View File
@@ -9,26 +9,46 @@ import (
"testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/tasks"
"keel/internal/ai"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
"keel/internal/settings"
"keel/internal/tasks"
"github.com/gin-gonic/gin"
)
func newTestServer(t *testing.T) *Server {
// newTestServer builds a harness around a freshly-constructed focus mode and
// returns the server plus the mode so tests can inject per-role stubs (the old
// controller's Set* API lives on focus.Mode) and assert on the typed focus
// state. The mode is registered behind a factory that wires the harness change
// notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on
// every focus change. The mode starts Locked; tests drive it through the
// command routes (e.g. POST /mode/command/planning), mirroring the original
// suite which POSTed /planning first.
func newTestServer(t *testing.T) (*Server, *focus.Mode) {
t.Helper()
gin.SetMode(gin.TestMode)
path := filepath.Join(t.TempDir(), "state.json")
ctrl, err := session.New(path)
m, err := focus.New(path)
if err != nil {
t.Fatalf("controller: %v", err)
t.Fatalf("focus.New: %v", err)
}
return NewServer(ctrl)
h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{
"focus": func(svc harness.Services) mode.Mode {
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m
},
})
s := NewServer(h)
if err := h.Start("focus"); err != nil {
t.Fatalf("harness.Start(focus): %v", err)
}
return s, m
}
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
@@ -40,53 +60,73 @@ func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRec
return w
}
// fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for
// the real-factory parity test without reaching any model.
type fakeBackend struct{}
func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil }
func (fakeBackend) Name() string { return "fake" }
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
if m.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", m.State().RuntimeState)
}
}
func TestCommitmentRejectsInvalidInput(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
w := post(t, r, "/commitment", body)
w := post(t, r, "/mode/command/commitment", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestIllegalTransitionReturns409(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
// /complete from Locked is illegal.
w := post(t, r, "/complete", "")
w := post(t, r, "/mode/command/complete", "")
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
// TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with
// the focus state nested under "mode" and the kind under "active_mode".
func TestEnvelopeShape(t *testing.T) {
s, _ := newTestServer(t)
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("payload missing active_mode: %s", js)
}
if !strings.Contains(js, `"mode"`) {
t.Fatalf("payload missing nested mode object: %s", js)
}
}
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// A focus update should appear in the serialized state.
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}})
// A focus update should appear in the serialized state envelope.
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
js := s.stateJSON()
if !strings.Contains(js, `"evidence"`) {
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
}
func TestLockedStateHasNullEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
// End any active session to return to Locked; a freshly-started focus mode is
// already Locked (Start does not auto-plan in this harness wiring).
if m.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState)
}
js := s.stateJSON()
if !strings.Contains(js, `"evidence":null`) {
t.Fatalf("locked payload should have null evidence: %s", js)
@@ -115,60 +160,60 @@ func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Prop
}
func TestCoachRouteHappyPath(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
s, m := newTestServer(t)
m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" {
if cv := m.State().Coach; cv != nil && cv.Status == "ready" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach)
t.Fatalf("coach never reached ready: %+v", m.State().Coach)
}
func TestCoachRouteOutsidePlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{})
s, m := newTestServer(t)
m.SetCoach(stubCoach{})
r := s.Router()
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCoachRouteInvalidJSON(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCoachRouteUnavailableDegrades(t *testing.T) {
s := newTestServer(t) // no SetCoach
s, m := newTestServer(t) // no SetCoach
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
t.Fatalf("unavailable coach should still 200, got %d", w.Code)
}
if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" {
if cv := m.State().Coach; cv == nil || cv.Status != "error" {
t.Fatalf("want error status, got %+v", cv)
}
}
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest {
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
}
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest {
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
}
}
@@ -180,22 +225,22 @@ func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles [
}
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
s, m := newTestServer(t)
m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// Two distinct on-task titles trip the nudge (history >= 2; first call has no
// debounce to wait on).
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
if d := m.State().Drift; d != nil && d.Nudge != "" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
}
func TestServesStaticAssets(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
cases := []struct {
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return s.list, nil
}
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
s, m := newTestServer(t)
m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
if tv := m.State().Tasks; tv != nil && tv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
}
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
}
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
t.Fatalf("commitment did not carry allowed classes: %#v", got)
}
// Now Active: overrides succeed.
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK {
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
}
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK {
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
}
}
@@ -298,15 +345,15 @@ func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, e
}
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}})
s, m := newTestServer(t)
m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" {
if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -329,20 +376,20 @@ func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.
}
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
s, m := newTestServer(t)
m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" {
if rv := m.State().Reflection; rv != nil && rv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
t.Fatalf("review payload missing recap: %s", js)
}
// End -> Locked -> Planning: the carry-forward should surface on planning.
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
// End -> Locked: the harness releases the focus mode and goes idle. Re-start
// the mode (re-adopting the same in-memory focus, carry-forward intact) and
// re-enter Planning; the carry-forward should surface there.
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
js2 := s.stateJSON()
@@ -372,19 +424,19 @@ func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title stri
}
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
m.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -397,15 +449,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
}
func TestKnowledgePathSelectionReloads(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s, m := newTestServer(t)
m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
settings.Settings{}, func(ss settings.Settings) error {
s.ctrl.SetKnowledgePath(ss.KnowledgePath)
m.SetKnowledgePath(ss.KnowledgePath)
return nil
})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK {
@@ -413,10 +465,60 @@ func TestKnowledgePathSelectionReloads(t *testing.T) {
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
if kv := m.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge)
t.Fatalf("path selection did not reload: %+v", m.State().Knowledge)
}
// TestRealFocusFactoryParity drives the REAL focus.Factory through the harness
// (Start -> commitment -> complete -> end), proving the generalized routes work
// end-to-end against a focus mode the harness itself constructs from Services —
// not a pre-built mode injected by the test. focus.Factory auto-enters Planning
// on Start, so no explicit planning command is needed.
func TestRealFocusFactoryParity(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := harness.Services{
AI: ai.NewService(fakeBackend{}),
Clock: time.Now,
Dir: t.TempDir(),
}
h := harness.New(svc, map[string]harness.Factory{"focus": focus.Factory})
s := NewServer(h)
r := s.Router()
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
// Start auto-plans; the envelope should report focus active and Planning.
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("envelope missing active_mode focus: %s", js)
}
if !strings.Contains(js, `"runtime_state":"planning"`) {
t.Fatalf("expected planning after start: %s", js)
}
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"active"`) {
t.Fatalf("expected active after commitment: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"review"`) {
t.Fatalf("expected review after complete: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
// End drives focus to Locked; the harness releases it and goes idle.
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after end ActiveMode = %q, want idle", got)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Package winapi is the Windows Win32 binding layer for AntiDrift's OS ports.
// Package winapi is the Windows Win32 binding layer for Keel's OS ports.
// The syscall-bound code lives in windows-tagged files; this untagged file
// holds the pure path logic so it builds and is tested on every platform.
package winapi