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:
+1
-1
@@ -2,5 +2,5 @@
|
||||
.superpowers/
|
||||
|
||||
# Go
|
||||
/antidriftd
|
||||
/keeld
|
||||
*.test
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ package enforce
|
||||
import (
|
||||
"context"
|
||||
|
||||
"antidrift/internal/winapi"
|
||||
"keel/internal/winapi"
|
||||
)
|
||||
|
||||
// NewGuard returns the Windows window-minimize guard.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,7 +3,7 @@ package evidence
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"keel/internal/mode/focus/domain"
|
||||
)
|
||||
|
||||
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"antidrift/internal/winapi"
|
||||
"keel/internal/winapi"
|
||||
)
|
||||
|
||||
// pollInterval is how often the Windows sensor samples the foreground window.
|
||||
|
||||
@@ -86,4 +86,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
||||
Health: EvidenceHealth{Available: true},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 = ""
|
||||
@@ -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)
|
||||
@@ -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() {
|
||||
+1
-1
@@ -5,7 +5,7 @@ package statemachine
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"keel/internal/mode/focus/domain"
|
||||
)
|
||||
|
||||
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
|
||||
+1
-1
@@ -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{
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// goalsPath is the standing-goals note loaded for the off-screen brief.
|
||||
const goalsPath = "~/owc/goals-2026.md"
|
||||
|
||||
// maxBriefBytes caps the assembled brief so the propose prompt stays bounded.
|
||||
const maxBriefBytes = 8 * 1024
|
||||
|
||||
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
|
||||
const bugGlob = "bug-*.md"
|
||||
|
||||
// assembleBrief builds the off-screen prompt context from today's tasks, the
|
||||
// standing goals note, and the life-domain notes. It is best-effort: it never
|
||||
// panics and never returns an error, and it tolerates nil ports and missing
|
||||
// files (degrading to a short, mostly-empty brief).
|
||||
func (m *Mode) assembleBrief() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("## Today's tasks\n")
|
||||
b.WriteString(m.briefTasks())
|
||||
b.WriteString("\n")
|
||||
|
||||
if goals := m.briefGoals(); goals != "" {
|
||||
b.WriteString("## Goals\n")
|
||||
b.WriteString(goals)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if domains := m.briefLifeDomains(); domains != "" {
|
||||
b.WriteString("## Life domains\n")
|
||||
b.WriteString(domains)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return truncateBrief(b.String(), maxBriefBytes)
|
||||
}
|
||||
|
||||
// briefTasks lists today's task titles, or "(none)" when the port is absent,
|
||||
// errors, or returns nothing.
|
||||
func (m *Mode) briefTasks() string {
|
||||
if m.tasks == nil {
|
||||
return "(none)\n"
|
||||
}
|
||||
list, err := m.tasks.Today(context.Background())
|
||||
if err != nil || len(list) == 0 {
|
||||
return "(none)\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, t := range list {
|
||||
title := strings.TrimSpace(t.Title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("- ")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "(none)\n"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// briefGoals returns the goals note text, or "" when the port is absent or the
|
||||
// file is missing/empty.
|
||||
func (m *Mode) briefGoals() string {
|
||||
if m.know == nil {
|
||||
return ""
|
||||
}
|
||||
p, err := m.know.Load(context.Background(), goalsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(p.Text)
|
||||
}
|
||||
|
||||
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
|
||||
// port (so reads honor its truncation) and concatenates their text under one
|
||||
// section. Returns "" when the port is absent or no notes are readable. File
|
||||
// access stays behind the port; when the port is nil it is skipped entirely.
|
||||
func (m *Mode) briefLifeDomains() string {
|
||||
if m.know == nil {
|
||||
return ""
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, path := range matches {
|
||||
p, err := m.know.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(p.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("### ")
|
||||
b.WriteString(filepath.Base(path))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(text)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// truncateBrief clips s to at most max bytes with a marker, keeping the prompt
|
||||
// bounded. It backs up to a rune boundary so it never splits a multibyte rune.
|
||||
func truncateBrief(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "\n…(truncated)"
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
|
||||
// off-screen action from the life-domain frame and files it on confirm. It is
|
||||
// one-shot — after confirm or dismiss it goes inactive and the harness releases
|
||||
// it.
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/mode"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
const proposeTimeout = 60 * time.Second
|
||||
|
||||
const (
|
||||
statusPending = "pending"
|
||||
statusProposed = "proposed"
|
||||
statusError = "error"
|
||||
statusDone = "done"
|
||||
)
|
||||
|
||||
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
||||
type Mode struct {
|
||||
mu sync.Mutex
|
||||
async harness.Async
|
||||
ai *ai.Service
|
||||
tasks tasks.Provider
|
||||
know knowledge.Source // may be nil; assembleBrief must guard
|
||||
|
||||
status string
|
||||
gen int
|
||||
proposal *ai.OffscreenProposal
|
||||
errMsg string
|
||||
done bool
|
||||
}
|
||||
|
||||
var _ mode.Mode = (*Mode)(nil)
|
||||
|
||||
// New builds an off-screen mode from the shared services. It does not start the
|
||||
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
|
||||
func New(svc harness.Services) *Mode {
|
||||
m := &Mode{
|
||||
ai: svc.AI,
|
||||
tasks: svc.Tasks,
|
||||
know: svc.Knowledge,
|
||||
status: statusPending,
|
||||
}
|
||||
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mode) Kind() string { return "offscreen" }
|
||||
|
||||
// Active reports whether the mode still holds the harness. It goes false after
|
||||
// confirm or dismiss.
|
||||
func (m *Mode) Active() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return !m.done
|
||||
}
|
||||
|
||||
// Command routes a surface command. start (re)runs the brief; confirm files the
|
||||
// proposed action and finishes; dismiss finishes without writing.
|
||||
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
|
||||
switch name {
|
||||
case "start":
|
||||
return m.start()
|
||||
case "confirm":
|
||||
return m.confirm(ctx)
|
||||
case "dismiss":
|
||||
m.mu.Lock()
|
||||
m.done = true
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("offscreen: unknown command %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// start assembles the brief and kicks off the brain asynchronously. A new call
|
||||
// bumps the generation so a stale in-flight result is discarded.
|
||||
func (m *Mode) start() error {
|
||||
m.mu.Lock()
|
||||
m.gen++
|
||||
gen := m.gen
|
||||
m.status = statusPending
|
||||
m.proposal = nil
|
||||
m.errMsg = ""
|
||||
m.mu.Unlock()
|
||||
|
||||
brief := m.assembleBrief()
|
||||
|
||||
var p ai.OffscreenProposal
|
||||
var err error
|
||||
m.async.Run(proposeTimeout,
|
||||
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
|
||||
func() bool { return gen != m.gen },
|
||||
func() {
|
||||
if err != nil {
|
||||
m.status = statusError
|
||||
m.errMsg = err.Error()
|
||||
return
|
||||
}
|
||||
m.status = statusProposed
|
||||
m.proposal = &p
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirm files the proposed action as a task and ends the mode. It errors if
|
||||
// there is nothing to confirm yet.
|
||||
func (m *Mode) confirm(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
p := m.proposal
|
||||
m.mu.Unlock()
|
||||
if p == nil {
|
||||
return errors.New("offscreen: nothing to confirm")
|
||||
}
|
||||
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.status = statusDone
|
||||
m.done = true
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// View projects the mode's state for surfaces. Always JSON-marshalable.
|
||||
func (m *Mode) View() any {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v := map[string]any{"status": m.status}
|
||||
if m.proposal != nil {
|
||||
v["next_action"] = m.proposal.NextAction
|
||||
v["rationale"] = m.proposal.Rationale
|
||||
}
|
||||
if m.errMsg != "" {
|
||||
v["error"] = m.errMsg
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
|
||||
func Factory(svc harness.Services) mode.Mode {
|
||||
m := New(svc)
|
||||
_ = m.start()
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
// stubBackend is a local fake ai.Backend; ai's package-internal fake is not
|
||||
// reachable from this package, so we define our own here.
|
||||
type stubBackend struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (b stubBackend) Name() string { return "stub" }
|
||||
func (b stubBackend) Run(context.Context, string) (string, error) {
|
||||
return b.out, b.err
|
||||
}
|
||||
|
||||
type fakeTasks struct {
|
||||
created []string
|
||||
}
|
||||
|
||||
func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil }
|
||||
func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error {
|
||||
f.created = append(f.created, t.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestMode(t *testing.T, ft *fakeTasks, backend ai.Backend) *Mode {
|
||||
t.Helper()
|
||||
svc := harness.Services{
|
||||
AI: ai.NewService(backend),
|
||||
Tasks: ft,
|
||||
Clock: func() time.Time { return time.Unix(0, 0) },
|
||||
Notify: func() {},
|
||||
}
|
||||
return New(svc)
|
||||
}
|
||||
|
||||
// waitStatus polls the mode's View until status reaches want, or fails after 2s.
|
||||
func waitStatus(t *testing.T, m *Mode, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
v, _ := m.View().(map[string]any)
|
||||
if v != nil && v["status"] == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("status never reached %q (last view: %+v)", want, m.View())
|
||||
}
|
||||
|
||||
func okBackend() stubBackend {
|
||||
return stubBackend{out: `{"next_action":"call mum","rationale":"people goal"}`}
|
||||
}
|
||||
|
||||
func TestConfirmFilesExactlyOneTask(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, okBackend())
|
||||
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
waitStatus(t, m, statusProposed)
|
||||
|
||||
if err := m.Command(context.Background(), "confirm", nil); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if len(ft.created) != 1 {
|
||||
t.Fatalf("created %d tasks, want 1", len(ft.created))
|
||||
}
|
||||
if ft.created[0] != "call mum" {
|
||||
t.Fatalf("created task title = %q, want %q", ft.created[0], "call mum")
|
||||
}
|
||||
if m.Active() {
|
||||
t.Fatal("mode should be done after confirm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissWritesNothing(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, okBackend())
|
||||
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
waitStatus(t, m, statusProposed)
|
||||
|
||||
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
|
||||
t.Fatalf("dismiss: %v", err)
|
||||
}
|
||||
if len(ft.created) != 0 {
|
||||
t.Fatalf("dismiss created %d tasks, want 0", len(ft.created))
|
||||
}
|
||||
if m.Active() {
|
||||
t.Fatal("mode should be done after dismiss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeErrorSurfacesInView(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, stubBackend{err: errors.New("boom")})
|
||||
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
waitStatus(t, m, statusError)
|
||||
|
||||
v, _ := m.View().(map[string]any)
|
||||
if v == nil || v["error"] == nil || v["error"] == "" {
|
||||
t.Fatalf("expected error field in view, got %+v", v)
|
||||
}
|
||||
if !m.Active() {
|
||||
t.Fatal("mode should stay active on error so the user can retry or dismiss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmWithoutProposalErrors(t *testing.T) {
|
||||
ft := &fakeTasks{}
|
||||
m := newTestMode(t, ft, okBackend())
|
||||
// No start → no proposal.
|
||||
if err := m.Command(context.Background(), "confirm", nil); err == nil {
|
||||
t.Fatal("confirm without a proposal should error")
|
||||
}
|
||||
if len(ft.created) != 0 {
|
||||
t.Fatalf("confirm without proposal created %d tasks, want 0", len(ft.created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCommandErrors(t *testing.T) {
|
||||
m := newTestMode(t, &fakeTasks{}, okBackend())
|
||||
if err := m.Command(context.Background(), "bogus", nil); err == nil {
|
||||
t.Fatal("unknown command should error")
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"keel/internal/mode/focus/domain"
|
||||
)
|
||||
|
||||
func TestLoadMissingFileReturnsLocked(t *testing.T) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} }
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user