Compare commits
32 Commits
13633ffabf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb6adf7ef | |||
| b36964a427 | |||
| 6c81a0d4f8 | |||
| aaed1ad265 | |||
| fa178a1fd2 | |||
| ef845ce463 | |||
| 4b2cb4242f | |||
| 7507cc6d28 | |||
| c963895599 | |||
| af3a0e2ac8 | |||
| b37ee40dd2 | |||
| 2690fdd513 | |||
| 703218658b | |||
| 607ad74def | |||
| aac3173874 | |||
| 9d7786c3e6 | |||
| 682eb603fa | |||
| 6d296bf743 | |||
| c855731c39 | |||
| bd2b8a41fd | |||
| 16b44c9e90 | |||
| 5bdb98c1a7 | |||
| 67f91442a4 | |||
| 3a4f498f86 | |||
| 7d4f9ca4a8 | |||
| 582af0d68b | |||
| b422b6deb6 | |||
| 3386405c7c | |||
| 730ffe33ce | |||
| 7d69a1f320 | |||
| 258de2c14b | |||
| f9e580ff7e |
+1
-1
@@ -2,5 +2,5 @@
|
||||
.superpowers/
|
||||
|
||||
# Go
|
||||
/antidriftd
|
||||
/keeld
|
||||
*.test
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Keel — Agent Context
|
||||
|
||||
You are working in **Keel**, Felix's human-harness. Read this, then treat
|
||||
`docs/keel-architecture.md` as the source of truth for anything architectural.
|
||||
|
||||
## What this is
|
||||
|
||||
The inversion of an agent harness: instead of a harness watching a model work, a
|
||||
model (the brain) helps observe and steer a *human*. Felix is the observed
|
||||
subject; the brain is the operator. The thing we build is **the harness** — the
|
||||
framework that collects Felix's real state, hands it to a swappable brain, and
|
||||
acts on the reply.
|
||||
|
||||
- **Brain** — swappable, rented compute: `claude` / `codex` / **Hermes** (real,
|
||||
on another machine). Not the point; interchangeable behind one interface.
|
||||
- **Storage** — **ActivityWatch** buckets (`keel.state`, `keel.events`). No new
|
||||
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 (`~/.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.
|
||||
|
||||
## What runs today
|
||||
|
||||
**AntiDrift**, Keel's first *mode*: a Go focus daemon (ports-and-adapters) —
|
||||
`evidence.Source` (perception) · `tasks.Provider` (Marvin) · `knowledge.Source`
|
||||
(the `~/owc`-derived profile) · `ai.Backend` (the brain) · `enforce.Guard`
|
||||
(window-minimize). The cockpit work is **loosening its session controller** into a
|
||||
general *collect → brain → act* loop where a focus-session is one mode among many.
|
||||
|
||||
```bash
|
||||
go run ./cmd/keeld # local web UI at http://localhost:7777
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Naming / rename status
|
||||
|
||||
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
|
||||
|
||||
- **Reuse before building.** Read the code before claiming something is missing.
|
||||
AntiDrift already is the harness skeleton; widen it, don't rebuild.
|
||||
- **Reference, don't duplicate.** `~/owc` and each tool's store stay source of
|
||||
truth; Keel never becomes a hidden second source.
|
||||
- **Ship visible proof.** Every session ends with a real artifact — reframing and
|
||||
planning are not progress. Felix's named failure mode is tooling-as-avoidance;
|
||||
resist it.
|
||||
- **Concise coaching.** The status line is one signal / one action / one question.
|
||||
Not "nice summaries" — that was explicitly rejected.
|
||||
- **Local-first; mind secrets.** The operator runs as the full Unix user with no
|
||||
sandbox; window titles and on-disk keys can leak. Filter at the assembler.
|
||||
|
||||
## Pointers
|
||||
|
||||
- `docs/keel-architecture.md` — authoritative architecture (components, data flow,
|
||||
AW-as-storage, the open questions in §8).
|
||||
- `docs/superpowers/` — AntiDrift focus-mode feature specs/plans (historical).
|
||||
- `README.md` — project front door.
|
||||
@@ -1,18 +1,45 @@
|
||||
# AntiDrift
|
||||
# Keel
|
||||
|
||||
A personal focus operating system: treat each work session as an explicit
|
||||
commitment (next action, success condition, timebox), and make drift visible.
|
||||
A **human-harness** that helps Felix hold course toward his higher goals: it
|
||||
collects his real state from the tools he already uses, hands it to a swappable
|
||||
brain (`claude` / `codex` / Hermes), and acts on the reply through gated
|
||||
effectors — surfaced on a web UI and the WM status bar. The brain, the storage
|
||||
(ActivityWatch), and the data sources are reused; the harness is what we build.
|
||||
|
||||
See `docs/superpowers/specs/` for the design.
|
||||
> **Architecture:** [`docs/keel-architecture.md`](docs/keel-architecture.md) is the
|
||||
> source of truth. **Agents:** read [`AGENTS.md`](AGENTS.md) first.
|
||||
|
||||
## Modes
|
||||
|
||||
Keel's controller is now a generic **harness** that hosts one *mode* at a time,
|
||||
and the daemon ships two:
|
||||
|
||||
- **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
|
||||
|
||||
@@ -40,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
|
||||
@@ -69,12 +96,12 @@ 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.
|
||||
|
||||
M1 (evidence & audit): active-window tracking, two-tier evidence store
|
||||
(disposable per-session raw log + permanent hash-chained session summaries),
|
||||
and live SSE updates. Live drift judgment and the ambient nudge arrive in later
|
||||
milestones (see the roadmap in
|
||||
`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`).
|
||||
and live SSE updates. Live drift judgment and the ambient nudge arrived in later
|
||||
milestones (M3 and M3.5 above; the original roadmap spec has since been removed —
|
||||
code and git history are the record).
|
||||
|
||||
@@ -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,250 @@
|
||||
// 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/ambient"
|
||||
"keel/internal/aw"
|
||||
"keel/internal/enforce"
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/memory"
|
||||
"keel/internal/mode"
|
||||
"keel/internal/mode/focus"
|
||||
"keel/internal/mode/offscreen"
|
||||
"keel/internal/notify"
|
||||
"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)
|
||||
|
||||
// Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on
|
||||
// first run, but Load returns whatever the JSON file holds on later starts.
|
||||
awURL := s.AWURL
|
||||
if awURL == "" {
|
||||
awURL = "http://localhost:5600"
|
||||
}
|
||||
var mem memory.Store = memory.NewNop()
|
||||
awc := aw.New(awURL)
|
||||
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
|
||||
} else {
|
||||
mem = memory.NewAWStore(awc, "keel.events")
|
||||
log.Printf("memory: keel.events ready at %s", awURL)
|
||||
}
|
||||
|
||||
return harness.Services{
|
||||
AI: svc,
|
||||
Tasks: tasks.NewMarvin(s.MarvinCmd),
|
||||
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
|
||||
Enforce: enforce.NewGuard(),
|
||||
Memory: mem,
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
|
||||
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
|
||||
// mode is active. Backend/port changes need a daemon restart to reach it; live
|
||||
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
|
||||
// services").
|
||||
ambMode := cfg.AmbientMode
|
||||
if ambMode == "" {
|
||||
ambMode = settings.AmbientNotify
|
||||
}
|
||||
// Compute the effective cadence so the log reflects what the sentinel will
|
||||
// actually use: New clamps a non-positive value (e.g. a settings.json that
|
||||
// predates these fields) to a default, mirrored here for the log line.
|
||||
ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second
|
||||
if ambCadence <= 0 {
|
||||
ambCadence = 5 * time.Minute
|
||||
}
|
||||
sentinel := ambient.New(ambient.Deps{
|
||||
AI: base.AI,
|
||||
Knowledge: base.Knowledge,
|
||||
Tasks: base.Tasks,
|
||||
Notifier: notify.NewNotifier(),
|
||||
Memory: base.Memory,
|
||||
Clock: time.Now,
|
||||
ActiveMode: func() string { return h.State().ActiveMode },
|
||||
}, ambCadence, ambMode)
|
||||
log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence)
|
||||
|
||||
srv := web.NewServer(h)
|
||||
srv.Init() // re-arm or expire a restored deadline-bearing session
|
||||
|
||||
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
|
||||
sentinel.AddOnChange(srv.Broadcast)
|
||||
|
||||
// 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 {
|
||||
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
|
||||
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
|
||||
}
|
||||
next, err := buildServices(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.SetServices(next)
|
||||
mode := s.AmbientMode
|
||||
if mode == "" {
|
||||
mode = settings.AmbientNotify
|
||||
}
|
||||
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
|
||||
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, sentinel.Line)
|
||||
h.AddOnChange(writer.Wake)
|
||||
sentinel.AddOnChange(writer.Wake)
|
||||
go writer.Run(context.Background())
|
||||
log.Printf("status: writing %s", statusPath)
|
||||
}
|
||||
|
||||
// Feed the window sensor stream into BOTH the harness (for the active mode)
|
||||
// and the ambient sentinel (always-on). On a headless box the source is a
|
||||
// no-op, so both degrade gracefully without new X11 requirements.
|
||||
src := evidence.NewSource()
|
||||
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
|
||||
h.RecordWindow(w)
|
||||
sentinel.OnWindow(w)
|
||||
})
|
||||
go sentinel.Run(context.Background())
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: Keel — Architecture
|
||||
name: keel
|
||||
category: architecture
|
||||
created: 2026-06-04
|
||||
updated: 2026-06-04
|
||||
tags:
|
||||
- keel
|
||||
- human-harness
|
||||
- architecture
|
||||
- antidrift
|
||||
- activitywatch
|
||||
status: authoritative
|
||||
---
|
||||
|
||||
# Keel — Architecture
|
||||
|
||||
> **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 `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.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we're building (one sentence)
|
||||
|
||||
**A harness** — a framework that pulls Felix's real state out of his existing
|
||||
tools, hands it to a swappable brain, and turns the reply into actions and two
|
||||
surfaces. The brain, the storage, and the data sources are all **reused**; the
|
||||
harness is the only thing we build. The target is one system (Keel — the single
|
||||
screen), grown by **loosening AntiDrift's controller**, not by starting over.
|
||||
|
||||
The inversion holds: this is a *human-harness*. In an agent harness a model is
|
||||
the observed worker; here Felix is the observed subject and the model is the
|
||||
operator.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core decisions (locked 2026-06-04)
|
||||
|
||||
1. **The product is the harness.** The brain (`claude` / `codex` / **Hermes**) is
|
||||
rented, interchangeable compute reached over a CLI or the network — *not* the
|
||||
point. The value is the framework that feeds information *in* and acts on what
|
||||
comes *back*. Hermes is real (an agent harness on another machine); it is one
|
||||
brain option, not a dependency.
|
||||
|
||||
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**. 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
|
||||
(~1 yr / ~287k events). Keel gets its **own AW bucket(s)** for memory. **No new
|
||||
database, no new event store.** AW is therefore both a *sensor* (screen-time
|
||||
ground truth) and the *store* (Keel's memory).
|
||||
|
||||
4. **Two surfaces, both — no fork.**
|
||||
- **Web UI** — configure, interact, discuss; phone-friendly. The place Felix
|
||||
*talks to* Keel.
|
||||
- **WM status bar** — reflect current status (AntiDrift already writes
|
||||
`~/.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
|
||||
"clawd". Values, goals, the 22 `resources/bug-*.md`, weekly reviews,
|
||||
self-management frameworks, capture stream. **Reference, never duplicate.**
|
||||
|
||||
6. **Effectors: start gated, trend toward autonomy.** Day one: read + *propose,
|
||||
Felix confirms in the web UI*. Target: the brain does a lot on its own —
|
||||
**update Beeminder datapoints, update Marvin to-dos, update `~/owc` notes.**
|
||||
The gate (propose-and-confirm vs. just-do-it) is a **config surface per
|
||||
effector**, not a vibe — this is the agency-vs-cage line and it stays Felix's
|
||||
to set.
|
||||
|
||||
7. **Still in force:** don't build a new event store; AntiDrift is the substrate;
|
||||
reference source-of-truth; local-first; every session ends with visible proof;
|
||||
the status line stays small.
|
||||
|
||||
---
|
||||
|
||||
## 3. The harness (the components we build)
|
||||
|
||||
```
|
||||
SOURCES (read) KEEL (the framework = the product) BRAIN (swappable)
|
||||
───────────── ────────────────────────────────────── ──────────────────
|
||||
AW :5600 ───────┐ ┌──────────────────────────────────────┐ claude (local CLI)
|
||||
AntiDrift ──────┤ feed │ ① COLLECTORS → ③ ASSEMBLER → [BRAIN] │ send ──▶ codex (local CLI)
|
||||
HQ hq.db ───────┤ ───────▶ │ ▲ │ │ Hermes (remote API)
|
||||
Consume :8000 ──┤ │ │ ▼ │ ◀─ reply
|
||||
daily.db :2200 ─┤ │ ② MEMORY = AW buckets ④ EFFECTORS │ (just compute;
|
||||
Beeminder ──────┤ │ (reuse AW; no new DB) │ │ interchangeable)
|
||||
Marvin ammcp ───┘ └────────────────────────────────┼─────┘
|
||||
~/owc (frame) ──── grounds the brief ────────────────────────┤
|
||||
⑤ renders to ──┴──────────┐
|
||||
▼ ▼
|
||||
WEB UI WM STATUS BAR
|
||||
(configure/interact/discuss) (current status)
|
||||
```
|
||||
|
||||
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 ~/.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.
|
||||
|
||||
- **② Memory** — AW buckets owned by Keel. Keel writes its own derived events here
|
||||
(mode_started, proposal_made, action_taken, spiral_flagged, …) so it *remembers
|
||||
across runs* and can see trends a single tool can't. Rebuildable, queryable via
|
||||
AW's REST API. Depends on AW only. (See §7.)
|
||||
|
||||
- **③ Context assembler** — builds the brief handed to the brain = relevant
|
||||
collector signals + memory (recent + trend) + the `~/owc` frame (the value/goal
|
||||
at stake, the relevant `bug-*.md`). Budgets it. Depends on ① ② and `~/owc`.
|
||||
Privacy boundary lives here: titles that can leak secrets are filtered before
|
||||
they reach the brain.
|
||||
|
||||
- **Brain adapter** — pluggable. Local: shell `claude --print` / `codex exec`
|
||||
(exactly what AntiDrift's `ai.Backend` already does). Remote: call Hermes over
|
||||
the network. Interface: `decide(brief) -> {status, proposals[]}`. Keel treats it
|
||||
as a black box; swapping it changes nothing else.
|
||||
|
||||
- **④ Effectors** — one gated adapter per write target. Today's safe writes:
|
||||
Marvin task create/complete (`am` / `ammcp`, **preserve the `fieldUpdates`
|
||||
CRDT** on write), capture append (one file), AntiDrift start-session / enforce.
|
||||
Roadmap writes: Beeminder datapoint (`beeline`), `~/owc` note edits. Each
|
||||
effector reads its gate from config (propose-and-confirm | auto).
|
||||
|
||||
- **⑤ Surfaces** — render Keel's state. Web UI (rich, phone-friendly, the
|
||||
interaction/approval surface) and the WM status bar (one live line). Both read
|
||||
the same Keel state; neither owns data.
|
||||
|
||||
- **Control loop / scheduler** — drives the cycle: when to collect, when to think,
|
||||
when to surface, when to act. Cheap triggers (a switch-count spike, a Beeminder
|
||||
losedate approaching, a manual web-UI request) decide when to spend a brain
|
||||
call, instead of polling the LLM blindly. This is AntiDrift's state machine,
|
||||
generalized past the single session.
|
||||
|
||||
- **Policy / gates** — per-effector authority + the value frame. Felix's dial
|
||||
from "read-only" to "act freely". Configured in the web UI.
|
||||
|
||||
---
|
||||
|
||||
## 4. AntiDrift is the seed, not a peer
|
||||
|
||||
Keel is AntiDrift's port set, widened. The parts already exist:
|
||||
|
||||
| Keel part | AntiDrift today | Keel (generalized) |
|
||||
|---|---|---|
|
||||
| Collectors | `evidence.Source` (X11), `tasks.Provider` (Marvin) | + AW, HQ, Consume, daily, Beeminder, `~/owc` |
|
||||
| Frame | `knowledge.Source` → one file | `~/owc`: values, goals, 22 `bug-*.md`, frameworks |
|
||||
| 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` + `~/.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; 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
|
||||
|
||||
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 — plus recent
|
||||
proposals and their outcomes recalled from the `keel.events` AW bucket —
|
||||
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.
|
||||
|
||||
**Shipped:** memory. Off-screen now writes its decisions to the `keel.events` AW
|
||||
bucket (`proposal_made` / `action_taken` / `proposal_dismissed`, correlated by a
|
||||
random `proposal_id`) and reads its recent history back into the brief — so a
|
||||
proposal can avoid repeating itself and follow up on what was dismissed or left
|
||||
undone. Storage sits behind a thin `memory.Store` port (AW-backed, or a nop when
|
||||
AW is down), the seam every later mode reuses; see §7. The `keel.state` bucket and
|
||||
the trend/spiral detection that reads this data remain future increments.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sources & targets (the real ecosystem)
|
||||
|
||||
| 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 | `~/.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` |
|
||||
| Beeminder | `beeline` | `beeline` datapoints | committed-evidence rollups | Beeminder API |
|
||||
| Marvin | `ammcp` / `am --json` | `am` / `ammcp` (keep CRDT) | intent / to-dos / bugs | CouchDB |
|
||||
| `~/owc` | markdown globs, `.zk` | note edits (later) | values/goals/bugs frame | `~/owc` files |
|
||||
| Brain | — | — | rented compute | claude/codex/Hermes |
|
||||
|
||||
Known frictions Keel inherits (from the survey): beesync has **no scheduler**
|
||||
(Beeminder data lags; `[daily]` bridge commented out); the life-bug concept is
|
||||
**forked** (22 rich `~/owc` markdown bugs vs. 5 thin Marvin tasks); **capture is
|
||||
broken** (`blurt` → clipboard, dead `inbox.md`, two rival `stream.md`); AW's year
|
||||
of data is read by nothing today.
|
||||
|
||||
---
|
||||
|
||||
## 7. Memory in ActivityWatch (the storage design)
|
||||
|
||||
AW is a typed, append-only, timestamped event store with buckets + a REST/query
|
||||
API — i.e. it is already the event store the §0 correction said not to rebuild.
|
||||
Keel gets dedicated buckets (e.g. `keel.state`, `keel.events`) and writes
|
||||
**derived** events: `mode_started`, `proposal_made`, `feedback_given`,
|
||||
`action_taken`, `spiral_flagged`, `coach_line`. Properties:
|
||||
- **Reference, don't duplicate** — these hold pointers (a Marvin id, a `bug-*.md`
|
||||
path, an audit `session_id`), not copies of other tools' truth.
|
||||
- **Rebuildable** — derivable from the underlying ledgers; AW is a cache/index of
|
||||
Keel's own decisions plus a join surface over the sources.
|
||||
- **Queryable over time** — this is what unlocks trend/spiral detection ("caffeine
|
||||
bug + mood dip + rising switches, same shape as three weeks ago") that no single
|
||||
tool can do, because each tool only answers "what now".
|
||||
|
||||
Open sub-decision: whether work-laptop AW data is peer-synced in (cross-device
|
||||
behavioral story) or left out initially.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions (not yet decided)
|
||||
|
||||
- **Effector gate policy** — exact default authority per write target on day one,
|
||||
and how Felix raises/lowers it from the web UI.
|
||||
- **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.
|
||||
- **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/`)
|
||||
|
||||
- The whole `~/dev/higher/` planning directory: it was the scaffold for deciding
|
||||
to extend AntiDrift. This doc is now the authoritative home; that dir retires to
|
||||
a pointer.
|
||||
- "clawd" → **`~/owc`** everywhere.
|
||||
- The plan's premise that the operator "already knows Felix via `~/.hermes`" and
|
||||
the survey finding "Hermes doesn't exist" are both resolved: **Hermes is real
|
||||
but remote, and the brain is pluggable** — grounding comes from the `~/owc`
|
||||
frame regardless of which brain runs.
|
||||
- The plan's §6 recommendation ("a thin *reader* layer is the real architecture")
|
||||
is **superseded**: the reader is merely the **collectors layer** of Keel. The
|
||||
target is the full system (one screen, two surfaces, durable memory,
|
||||
gated→autonomous effectors), grown from AntiDrift.
|
||||
- "Don't build an event store" is **sharpened**: storage = **ActivityWatch
|
||||
buckets**.
|
||||
|
||||
---
|
||||
|
||||
## 10. Smallest real slice (a vertical, not a summary)
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
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.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Ambient Drift Coach — Design
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Scope:** Give Keel an always-on presence. Today Keel only coaches *inside* a
|
||||
declared focus session; the moment you are just working, the harness goes idle,
|
||||
drops every window snapshot, and the status bar reads `idle`. This slice adds an
|
||||
**ambient drift coach**: a sentinel that watches the window stream continuously
|
||||
(even when no mode is active), judges your recent activity against the broad
|
||||
`~/owc` frame (goals, values, life-domains) plus today's Marvin tasks, and —
|
||||
when you have clearly slid off what matters — surfaces a single coaching line to
|
||||
the status bar, a `notify-send` toast, and a web-UI banner you can act on.
|
||||
|
||||
This builds the **Control loop / scheduler** component from
|
||||
`docs/keel-architecture.md` §3, which today has zero implementation, and is the
|
||||
first thing that makes Keel *show up unprompted* (SOUL: "when my partner drifts,
|
||||
I name it").
|
||||
|
||||
## Problem
|
||||
|
||||
The focus mode is already a capable proactive coach — local on-task matching, an
|
||||
LLM drift judge, a semantic "nudge" over recent window titles, surfaced to the
|
||||
status bar and web UI, all sensibly debounced. But every bit of it hangs off a
|
||||
**declared `Commitment`**. You must open the UI and start a session first. With
|
||||
no session:
|
||||
|
||||
- `harness.RecordWindow` finds no active mode and silently drops the snapshot.
|
||||
- `statusfile.Render` of an empty envelope returns `idle`.
|
||||
- No brain call ever happens; Keel is blind and mute.
|
||||
|
||||
So the common case — Felix working without ceremonially declaring a task — gets
|
||||
nothing. The fix is an observer that runs regardless of the active mode and is
|
||||
grounded in the standing frame instead of a per-session commitment.
|
||||
|
||||
## Approach (and the one real fork)
|
||||
|
||||
The ambient coach is **not a mode**. It is an always-on **sentinel** that runs
|
||||
*beside* the one-mode harness. Two harness invariants make "ambient as a mode"
|
||||
the wrong shape:
|
||||
|
||||
1. The harness hosts exactly one mode and drops evidence when idle — an ambient
|
||||
watcher must run continuously, including when idle.
|
||||
2. `harness.Start` returns `ErrBusy` if a different mode is active — an ambient
|
||||
mode would either block you from starting focus, or need a special
|
||||
auto-yield carve-out that erodes the single-mode contract.
|
||||
|
||||
A sentinel sidesteps both and matches §3, where the control loop is a distinct
|
||||
component beside the harness. **Coexistence rule:** when any harness mode is
|
||||
active (`h.State().ActiveMode != ""`), the sentinel stays silent — focus mode
|
||||
already owns coaching during a declared session, so there is never double
|
||||
coaching.
|
||||
|
||||
## Components
|
||||
|
||||
Each unit has one responsibility, a defined interface, and is testable alone.
|
||||
|
||||
### 1. `internal/notify` — notification effector (new)
|
||||
|
||||
The OS-level "interrupt me" primitive, mirroring `enforce.Guard`.
|
||||
|
||||
- **Interface:** `Notifier interface { Notify(ctx context.Context, title, body string) error }`
|
||||
- **Linux adapter:** shells `notify-send <title> <body>`. Best-effort: returns
|
||||
an error for diagnostics; callers never block on it.
|
||||
- **Constructor:** `NewNotifier() Notifier` returns the real adapter when the
|
||||
`notify-send` binary is found on `PATH` (via `exec.LookPath`), else a nop. On
|
||||
non-Linux it is a nop. This means a missing binary degrades to "no toast,"
|
||||
never a failure.
|
||||
- **Nop:** `Notify` returns nil, does nothing.
|
||||
- **Depends on:** `os/exec` only. Leaf package.
|
||||
|
||||
### 2. `internal/frame` — shared frame assembler (new; DRY extraction)
|
||||
|
||||
The "what matters to Felix" brief, today duplicated inside off-screen's
|
||||
`collect.go`. Extracting it removes the duplication and gives the sentinel the
|
||||
same grounding off-screen uses.
|
||||
|
||||
- **Interface:** `Assemble(ctx context.Context, know knowledge.Source, tasks tasks.Provider) string`
|
||||
returns a bounded markdown brief with three sections: **Today's tasks**
|
||||
(from `tasks.Today`), **Goals** (`~/owc/goals-2026.md` via `knowledge.Load`),
|
||||
and **Life domains** (every `~/owc/resources/bug-*.md` via `knowledge.Load`).
|
||||
- **Best-effort:** never panics, never errors; tolerates nil ports and missing
|
||||
files, degrading to a short, mostly-empty brief. Same truncation cap
|
||||
(`maxBriefBytes`) and rune-safe clipping off-screen uses today.
|
||||
- **Refactor:** off-screen's `briefTasks` / `briefGoals` / `briefLifeDomains`
|
||||
are replaced by a call to `frame.Assemble`; off-screen keeps its own
|
||||
proposal-history section (`briefHistory`) prepended around it. Off-screen's
|
||||
existing tests must stay green.
|
||||
- **Depends on:** `knowledge`, `tasks`. Imports nothing from `ai`/`mode`.
|
||||
|
||||
### 3. `internal/ai` — `AmbientDrift` method (new; mirrors `Nudge`)
|
||||
|
||||
The brain call grounded in the frame, not a commitment.
|
||||
|
||||
- **Method:** `func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)`
|
||||
returns `""` when the activity plausibly serves the frame (on-track), and a
|
||||
one-sentence coaching line when it is a slide.
|
||||
- **Prompt:** an "ambient coach" prompt stating the user has **not** declared a
|
||||
task; it is handed the frame and the recent window-title sequence and asked
|
||||
whether the activity serves **any** of what matters, **forgivingly** —
|
||||
legitimate breaks, rest, and unplanned-but-useful work are on-track. Output is
|
||||
`ONLY` JSON: `{"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}`.
|
||||
- **Parse (`parseAmbientDrift`):** reuses `extractJSON` and the shared
|
||||
`ErrEmptyResponse` / `ErrNoJSON` sentinels. `drifting:false` → `""`.
|
||||
`drifting:true` with a message → the message. `drifting:true` with no message →
|
||||
`""` (silence) — the ambient signal is advisory, so the safe degenerate is to
|
||||
say nothing, exactly like `parseNudge`.
|
||||
- **Depends on:** the existing `Backend`. `ai` stays a leaf (primitives only).
|
||||
|
||||
### 4. `internal/ambient` — the Sentinel (new; the meat)
|
||||
|
||||
Always-on observer: consume evidence → tick on cadence → judge → surface.
|
||||
|
||||
**Construction:** `New(deps)` where deps carry `*ai.Service`, `knowledge.Source`,
|
||||
`tasks.Provider`, `notify.Notifier`, `memory.Store`, `clock func() time.Time`,
|
||||
and `activeMode func() string` (reads `h.State().ActiveMode` for the coexistence
|
||||
rule). Config: `cadence time.Duration` and `mode` (`off` | `status` | `notify`).
|
||||
|
||||
**State (under one mutex):** `recentTitles []string` (consecutive-dedup ring,
|
||||
capped like focus's `recentTitlesMax`), `lastWindow evidence.WindowSnapshot`,
|
||||
`line string` (current drift line; `""` when on-track), `lastEvalTitles`
|
||||
(snapshot of the ring at the last brain call), `wasDrifting bool`,
|
||||
`episodeNotified bool`, `snoozeUntil time.Time`, `gen int`, and `onChange []func()`.
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `OnWindow(snap evidence.WindowSnapshot)` — called from the evidence fan-out.
|
||||
Updates `lastWindow`, appends the title to the ring (skip consecutive dup,
|
||||
cap). Cheap and lock-bounded; fires no brain call.
|
||||
- `Run(ctx context.Context)` — cadence loop: a ticker every `cadence` calls
|
||||
`evaluate()`; returns on ctx cancel.
|
||||
- `evaluate()` — the decision (see Trigger discipline below).
|
||||
- `Line() string` — current ambient line for the surfaces (`""` when on-track,
|
||||
quiet, or snoozed).
|
||||
- `Snooze(d time.Duration)` — sets `snoozeUntil = clock()+d`; clears the line and
|
||||
episode flags; fires `onChange`. While snoozed, `evaluate()` returns early.
|
||||
- `SetConfig(cadence time.Duration, mode string)` — live settings update.
|
||||
- `AddOnChange(f func())` — register a surface refresh (status wake, web
|
||||
broadcast). Fired whenever the line changes.
|
||||
|
||||
**Depends on:** `ai`, `frame`, `notify`, `memory`, `evidence`, and a
|
||||
`activeMode` closure. It does **not** import `harness` (closure injection keeps
|
||||
it decoupled and testable).
|
||||
|
||||
### 5. Wiring — `cmd/keeld/main.go`
|
||||
|
||||
- **Build** the notifier (`notify.NewNotifier()`) and the sentinel from settings
|
||||
(cadence, mode) and the shared ports (the same `ai.Service`, `knowledge`,
|
||||
`tasks`, `memory`, clock used to build `harness.Services`), with
|
||||
`activeMode: func() string { return h.State().ActiveMode }`.
|
||||
- **Fan the evidence stream to both** the harness and the sentinel:
|
||||
```go
|
||||
go src.Watch(ctx, func(w evidence.WindowSnapshot) {
|
||||
h.RecordWindow(w)
|
||||
sentinel.OnWindow(w)
|
||||
})
|
||||
```
|
||||
- **Run** the sentinel: `go sentinel.Run(ctx)`.
|
||||
- **Surface refresh:** register both the status writer's `Wake` and the web
|
||||
broadcast with `sentinel.AddOnChange(...)`, so a new ambient line re-renders
|
||||
the bar and pushes over SSE promptly.
|
||||
- **Live settings:** `applyFn` (the existing POST `/settings` re-wire) also calls
|
||||
`sentinel.SetConfig(...)` from the new settings, so the dial takes effect
|
||||
without a restart.
|
||||
|
||||
## Trigger discipline (aggressive, but not trigger-happy)
|
||||
|
||||
A `notify-send` toast on a false positive is far more irritating than a stale
|
||||
status line, so `evaluate()` is conservative and brain-call-thrifty:
|
||||
|
||||
1. If `mode == off` → return.
|
||||
2. If snoozed (`clock() < snoozeUntil`) → return.
|
||||
3. If `activeMode() != ""` → a mode is coaching; clear the line (if set), fire
|
||||
`onChange`, return. (Coexistence rule.)
|
||||
4. **Cheap gate:** if the ring is unchanged since `lastEvalTitles` **and** we are
|
||||
currently on-track (`line == ""`) → skip the brain call. This naturally
|
||||
pauses calls during steady deep work *and* while AFK (no new titles arrive in
|
||||
either case), while still re-checking an unresolved drift.
|
||||
5. Assemble the frame (`frame.Assemble`) and call
|
||||
`ai.AmbientDrift(ctx, frame, recentTitles)` under a timeout.
|
||||
6. Apply the result:
|
||||
- **On-track (`""`):** if a line was set, clear it and reset
|
||||
`wasDrifting=false`, `episodeNotified=false`; fire `onChange`. Otherwise no-op.
|
||||
- **Drifting (`msg`):**
|
||||
- **New episode** (`!wasDrifting`): set `line=msg`, `wasDrifting=true`,
|
||||
`episodeNotified=false`; fire `onChange`. The **status bar / banner show
|
||||
immediately**, but **no toast yet**.
|
||||
- **Sustained** (already `wasDrifting` on this consecutive eval): record an
|
||||
`ambient_nudge` event to `memory` (`{ "message": msg, "title":
|
||||
lastWindow.Title, "class": lastWindow.Class }`), and — if `mode == notify`
|
||||
and `!episodeNotified` — fire exactly one `notify-send` toast and set
|
||||
`episodeNotified=true`. Further sustained ticks do not re-toast.
|
||||
- On a brain **error**, leave the prior line intact and log — never fabricate
|
||||
drift (same discipline as `Nudge`).
|
||||
7. Update `lastEvalTitles` to the current ring.
|
||||
|
||||
Net effect with the default ~5-minute cadence: a 40-second glance at Twitter
|
||||
never pops a toast (it resolves before the second tick); a sustained ~10-minute
|
||||
slide shows on the bar at the first tick and pops one toast at the second.
|
||||
Memory records only *sustained* drifts, independent of the surface mode.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- **Status bar (`internal/statusfile`):** `Render` gains the current ambient line
|
||||
(passed in by the `Writer`, which reads `sentinel.Line`). When the envelope is
|
||||
idle (`ActiveMode == ""`): a non-empty line renders `⚠ <line>`; an empty line
|
||||
renders `idle` as today. Non-idle envelopes are unchanged. The `Writer` adds an
|
||||
`ambientLine func() string` source and `sentinel.AddOnChange(writer.Wake)`
|
||||
drives prompt re-render; the existing minute ticker still applies.
|
||||
- **Web UI (`internal/web` + `static/app.js`):** the served state gains an
|
||||
`"ambient": "<line>"` field (read from `sentinel.Line`), pushed over the
|
||||
existing SSE channel via `sentinel.AddOnChange(broadcast)`. `app.js` renders a
|
||||
**banner** when the field is non-empty: the coaching line plus two controls —
|
||||
**Snooze 1h** (`POST /ambient/snooze`) and **Start focus** (reuses the existing
|
||||
start-session affordance so a nudge converts into a declared session). The
|
||||
banner clears automatically when the line goes empty.
|
||||
- **New endpoint:** `POST /ambient/snooze` → `sentinel.Snooze(1 * time.Hour)`.
|
||||
Returns 204. No body needed in v1.
|
||||
|
||||
## Config (the agency-vs-cage dial, §8)
|
||||
|
||||
`settings.Settings` gains two fields (env-seedable, mirroring the existing ones):
|
||||
|
||||
- `ambient_mode` (`KEEL_AMBIENT_MODE`): `off` | `status` | `notify`. Default
|
||||
`notify`. Validated in the settings applier (like `ai_backend`); an unknown
|
||||
value is rejected with a wrapped sentinel so POST `/settings` can map it to 400.
|
||||
- `ambient_cadence_secs` (`KEEL_AMBIENT_CADENCE_SECS`): integer seconds between
|
||||
evaluations. Default `300`. A non-positive value falls back to the default.
|
||||
|
||||
The web settings form gains a select (`ambient_mode`) and a number input
|
||||
(`ambient_cadence_secs`); `applyFn` pushes both into `sentinel.SetConfig`.
|
||||
|
||||
## Error handling & graceful degradation
|
||||
|
||||
- **No `notify-send` binary / non-Linux:** nop notifier; status line and banner
|
||||
still work.
|
||||
- **Brain error / timeout:** `AmbientDrift` returns an error; the sentinel logs
|
||||
and leaves the prior line intact. Never fabricates drift.
|
||||
- **AW down:** `memory` is already the nop store; `ambient_nudge` writes are
|
||||
dropped silently.
|
||||
- **`knowledge` / `tasks` errors:** `frame.Assemble` degrades to a partial brief
|
||||
(best-effort), never errors.
|
||||
- **Headless / no X11:** the evidence source is a no-op, so `OnWindow` is never
|
||||
called, the ring stays empty, the cheap gate skips every brain call, and the
|
||||
sentinel is silent. No new X11 requirement.
|
||||
- **Nothing in the sentinel can crash the daemon** — every external call is
|
||||
best-effort and lock-bounded.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`internal/ambient`** (fake clock, fake `ai` with scriptable `AmbientDrift`
|
||||
returns and a call counter, fake `notify.Notifier` recording calls, fake
|
||||
`tasks`/`knowledge`, `activeMode` stub, `memory.Fake`):
|
||||
- on-track → no line, no toast, no memory.
|
||||
- single drift tick → line set, **no** toast, no memory yet.
|
||||
- two consecutive drift ticks → line set, **exactly one** toast, one
|
||||
`ambient_nudge` event.
|
||||
- third consecutive drift tick → still one toast (once per episode).
|
||||
- back on-track → line cleared, flags reset.
|
||||
- `activeMode() != ""` → no eval, line cleared.
|
||||
- `mode == status` → drift sets the line but never toasts (memory still records
|
||||
the sustained drift).
|
||||
- `mode == off` → nothing happens, no brain call.
|
||||
- snooze → no eval / no line during the window.
|
||||
- cheap gate → unchanged ring + on-track → assert the fake `ai` call count does
|
||||
**not** increase.
|
||||
- **`internal/notify`:** `NewNotifier` with `notify-send` absent → nop; `Notify`
|
||||
returns nil, no panic. (The real shell path is not unit-tested.)
|
||||
- **`internal/ai`:** `parseAmbientDrift` — on-track→`""`, drift+msg→msg, drift
|
||||
with no msg→`""`, empty→`ErrEmptyResponse`, no-JSON→`ErrNoJSON`.
|
||||
- **`internal/frame`:** `Assemble` with fake ports — sections present; nil ports
|
||||
and missing files degrade without error; truncation honored. Off-screen's
|
||||
existing tests stay green after the refactor.
|
||||
- **`internal/statusfile`:** idle + ambient line → `⚠ …`; idle + empty line →
|
||||
`idle`; non-idle envelopes unaffected.
|
||||
- **`internal/web`:** served state includes `ambient`; `POST /ambient/snooze`
|
||||
routes to `Snooze` and returns 204.
|
||||
- **`internal/settings`:** `ambient_mode` validation (good values accepted,
|
||||
unknown rejected with the sentinel); cadence default on non-positive.
|
||||
|
||||
## Out of scope (deliberate, for a later slice)
|
||||
|
||||
- **AFK precision via the AW afk bucket.** v1 uses the cheap-gate heuristic
|
||||
(no new titles ⇒ no call), which covers both AFK and deep-focus well enough.
|
||||
Reading AW's input-derived afk bucket to *prove* presence is a future collector.
|
||||
- **Ambient enforcement** (window-minimize). The sentinel is advisory only;
|
||||
minimizing windows with no declared session would be far too cagey. Enforcement
|
||||
stays a focus-session decision.
|
||||
- **Trend / spiral detection** over the accumulating `ambient_nudge` history
|
||||
(§7). This slice *produces* that history; reading it for cross-time patterns is
|
||||
the next increment.
|
||||
- **Richer banner actions** beyond snooze + start-focus (e.g. "I'm on it",
|
||||
per-domain mute).
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Default cadence.** 300 s is a starting guess; may want tuning after living
|
||||
with it. Configurable, so cheap to change.
|
||||
- **Snooze duration.** Fixed 1 h in v1; a picker is a later refinement.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Off-screen Memory — AW-backed Recall — Design
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Scope:** Give Keel its first durable, cross-run memory by wiring **one** mode
|
||||
(off-screen) to a real ActivityWatch bucket. Off-screen records its own
|
||||
decisions (`proposal_made` / `action_taken` / `proposal_dismissed`) to
|
||||
`keel.events` and reads its recent history back into the next brief, so
|
||||
proposals stay fresh and follow up on what was not acted on. The memory layer
|
||||
is introduced as a thin reusable port (`memory.Store`) behind which AW, HTTP,
|
||||
and bucket names are sealed — the seam every later mode reuses.
|
||||
|
||||
This realizes the "Next increment: memory" note in
|
||||
`docs/keel-architecture.md` §5 and the storage design in §7, at the smallest
|
||||
honest vertical.
|
||||
|
||||
## Problem
|
||||
|
||||
Keel has no durable memory. Mode state dies in `~/.keel/.../state.json`; the
|
||||
off-screen mode cannot remember what it proposed last time, so it can repeat
|
||||
itself and can never follow up ("you said you'd walk before the call — did
|
||||
you?"). The architecture (§7) resolves storage to **ActivityWatch buckets**
|
||||
(no new DB), because AW is an append-only, timestamped, queryable event store —
|
||||
the substrate that later unlocks trend/spiral detection no single tool can do.
|
||||
Today, zero AW client code exists in the repo and no `keel.*` bucket exists.
|
||||
|
||||
This design builds the first real slice of that memory, and only that slice.
|
||||
|
||||
## Decisions (locked in brainstorming 2026-06-05)
|
||||
|
||||
1. **Slice width = thin port + one consumer.** Add a generic `memory.Store`
|
||||
port to `harness.Services` (write an event, query recent), backed by AW, but
|
||||
wire exactly one real behavior: off-screen remembers. Focus and other event
|
||||
kinds come later.
|
||||
2. **Behavior = fresh + follow-up.** The brief includes recent proposals *and*
|
||||
their outcome (confirmed / dismissed / unactioned). The brain is told to
|
||||
avoid blindly repeating and to follow up on a recent unactioned/dismissed one
|
||||
when it still fits.
|
||||
3. **Brain-only (no UI this slice).** Memory shapes the proposal; nothing new is
|
||||
rendered. A "recently proposed" UI panel is a trivial later follow-up once the
|
||||
data is flowing.
|
||||
4. **AW from the start, behind a port.** Not the existing JSON `store` package —
|
||||
§3/§7 lock storage to AW precisely for queryability. Modes never touch HTTP.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two new leaf packages, matching the existing ports-and-adapters style
|
||||
(`evidence.Source`, `knowledge.Source`, `tasks.Provider`, `ai.Backend`,
|
||||
`enforce.Guard`):
|
||||
|
||||
### `internal/aw` — thin AW REST client
|
||||
|
||||
No Keel concepts; just buckets and events. Grounded in the live API
|
||||
(`/api/0`, AW server v0.13.2): events are `{id, timestamp, duration, data{}}`,
|
||||
and `GET .../events?limit=N` returns newest-first.
|
||||
|
||||
```go
|
||||
type Event struct {
|
||||
Timestamp time.Time
|
||||
Duration float64
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
type Client struct { /* baseURL, *http.Client with timeout */ }
|
||||
|
||||
func New(baseURL string) *Client
|
||||
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error // idempotent
|
||||
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error // POST .../events
|
||||
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) // GET .../events?limit=N
|
||||
```
|
||||
|
||||
- `EnsureBucket` is idempotent: creating an existing bucket is treated as
|
||||
success (AW returns a benign "already exists" response).
|
||||
- `Insert` posts a single event (`duration: 0` for discrete derived events).
|
||||
- Any transport/HTTP-status error is returned; callers treat memory as
|
||||
best-effort.
|
||||
|
||||
### `internal/memory` — Keel-facing port + adapters
|
||||
|
||||
```go
|
||||
type Event struct {
|
||||
Kind string // "proposal_made", "action_taken", "proposal_dismissed"
|
||||
At time.Time
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Record(ctx context.Context, e Event) error
|
||||
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||
}
|
||||
```
|
||||
|
||||
- `awStore` implements `Store` over `*aw.Client`, bucket id `keel.events`. It
|
||||
maps `memory.Event ↔ aw.Event`, carrying `Kind` inside `data._kind` and the
|
||||
rest of `Data` alongside. Because `keel.events` interleaves all kinds,
|
||||
`Recent(kind, n)` **over-fetches** a bounded window of recent bucket events
|
||||
(a fixed cap, e.g. 200), filters by `_kind` client-side, and returns up to `n`
|
||||
newest. Events older than that window are not seen — acceptable for "recent",
|
||||
and revisited if/when the event volume grows.
|
||||
- `nopStore` — `Record`/`Recent` no-ops; used when AW is unreachable/disabled.
|
||||
- `fake` — in-memory `Store` for tests (records appended in memory, `Recent`
|
||||
filters and returns newest-first).
|
||||
|
||||
### Wiring
|
||||
|
||||
- Add `Memory memory.Store` to `harness.Services`.
|
||||
- `cmd/keeld.buildServices` constructs the AW-backed store: base URL from a new
|
||||
`KEEL_AW_URL` setting (default `http://localhost:5600`), then
|
||||
`EnsureBucket("keel.events", "keel.event", "keel")` **once at startup**. If
|
||||
that fails, log once and fall back to `nopStore` so a missing/down AW never
|
||||
breaks boot.
|
||||
- `offscreen.New` / `offscreen.Factory` receive `svc.Memory`.
|
||||
|
||||
Modes depend only on the `memory.Store` interface; AW, HTTP, and bucket names
|
||||
stay sealed inside the adapter — same shape as every other port.
|
||||
|
||||
## Data model
|
||||
|
||||
`keel.events` is an append-only log of Keel's **derived** events. This slice
|
||||
writes three kinds, correlated by a `proposal_id` (short random hex minted when
|
||||
a proposal is made; `crypto/rand`, in the daemon, not the sandbox):
|
||||
|
||||
| Kind | When | `data` |
|
||||
|---|---|---|
|
||||
| `proposal_made` | brain returns a proposal | `{proposal_id, mode:"offscreen", next_action, rationale}` |
|
||||
| `action_taken` | confirm succeeds | `{proposal_id, mode:"offscreen", next_action}` |
|
||||
| `proposal_dismissed` | dismiss | `{proposal_id, mode:"offscreen"}` |
|
||||
|
||||
**Reference, don't duplicate (§7):** `next_action` is Keel's *own* output, not
|
||||
another tool's source of truth — recording it logs Keel's decision, it does not
|
||||
duplicate Marvin. The Marvin task id is *not* captured because
|
||||
`tasks.Provider.Create` returns only `error`; surfacing the real id is a future
|
||||
enhancement gated on that signature and is out of scope here.
|
||||
|
||||
## Data flow
|
||||
|
||||
### Write path (all best-effort)
|
||||
|
||||
A memory write failure is logged and swallowed; it never fails the command.
|
||||
|
||||
- Proposal produced (async `onComplete`, status → `proposed`): mint a
|
||||
`proposal_id`, stash it on the mode, `Record(proposal_made)`.
|
||||
- `confirm()`: after `tasks.Create` succeeds, `Record(action_taken)`.
|
||||
- `dismiss()`: `Record(proposal_dismissed)`.
|
||||
|
||||
### Read path
|
||||
|
||||
`offscreen.assembleBrief()` gains a `## Recently proposed` section:
|
||||
|
||||
1. `Recent("proposal_made", 5)` (newest-first); drop entries older than ~7 days.
|
||||
2. For each, resolve outcome by matching `proposal_id` against recent
|
||||
`action_taken` / `proposal_dismissed` → label `confirmed` / `dismissed` /
|
||||
`unactioned`, with a relative age.
|
||||
3. Render lines, e.g. `- "Walk before the call" (confirmed, 2h ago)`.
|
||||
4. `buildProposePrompt` gains a rule: *don't repeat these; if a recent one was
|
||||
dismissed or unactioned and still fits, follow up on it instead of inventing
|
||||
new.*
|
||||
|
||||
If `Memory` is nil or `Recent` errors, the section is omitted — the proposal
|
||||
still happens, ungrounded by history.
|
||||
|
||||
## Error handling / degradation
|
||||
|
||||
Memory is never on the critical path; it degrades exactly like the `knowledge`
|
||||
and `tasks` ports already do in this mode.
|
||||
|
||||
- AW down at **startup** → `EnsureBucket` fails → log once, use `nopStore`.
|
||||
Off-screen works, unremembering.
|
||||
- AW down **mid-run** → `Record`/`Recent` error → logged, swallowed.
|
||||
Propose / confirm / dismiss all still succeed.
|
||||
|
||||
## Testing
|
||||
|
||||
- `internal/aw` — against an `httptest` server: asserts the POST event body and
|
||||
the `GET ?limit=N` round-trip; an error status surfaces as an error.
|
||||
- `internal/memory` — `awStore` against `httptest` (the `_kind` mapping;
|
||||
`Recent` filters by kind); `nopStore` trivial; the `fake` exercised directly.
|
||||
- `internal/mode/offscreen` — using the `fake` store: (a) `proposal_made`
|
||||
written on propose, (b) `action_taken` on confirm, (c) `proposal_dismissed`
|
||||
on dismiss, (d) brief contains recent proposals with outcome labels, (e)
|
||||
everything still works with a nil/erroring store. Existing offscreen tests get
|
||||
the `fake` (or nil) injected — no behavior regressions.
|
||||
|
||||
## Out of scope (named to prevent scope creep)
|
||||
|
||||
- No `keel.state` bucket — only `keel.events`.
|
||||
- No focus-mode events; no `mode_started` / `feedback_given` / `spiral_flagged`
|
||||
/ `coach_line`.
|
||||
- No UI rendering of history (brain-only).
|
||||
- No spiral/trend detection — the *reason* for AW, but a later slice that reads
|
||||
this data.
|
||||
- No cross-device AW sync, no Marvin-id capture, no assembler privacy-filter
|
||||
rework.
|
||||
|
||||
## What ships
|
||||
|
||||
Off-screen mode that writes its decisions to a real AW bucket and reads its own
|
||||
history back into the next proposal — fresher proposals that follow up on what
|
||||
was not done. The first durable-memory vertical, end to end, and the seam every
|
||||
later mode reuses.
|
||||
@@ -0,0 +1,79 @@
|
||||
// internal/ai/ambient.go
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AmbientDriftJudge judges whether current activity serves the user's standing
|
||||
// frame when NO task is declared. Like Nudge it takes primitives, not domain
|
||||
// types, so ai stays a leaf. The returned string is a one-sentence advisory, or
|
||||
// "" when the activity is plausibly on-track.
|
||||
type AmbientDriftJudge interface {
|
||||
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||
}
|
||||
|
||||
// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response.
|
||||
var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift")
|
||||
|
||||
// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend.
|
||||
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) {
|
||||
out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseAmbientDrift(out)
|
||||
}
|
||||
|
||||
func buildAmbientPrompt(frame string, recentTitles []string) string {
|
||||
return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame — goals, values, and life-domains — and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift.
|
||||
|
||||
Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters.
|
||||
|
||||
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||
{"drifting": <true or false>, "message": "<short explanation, one sentence>"}
|
||||
|
||||
Rules:
|
||||
- drifting: true only if the recent activity serves none of the frame; false otherwise.
|
||||
- message: one short sentence naming the drift. REQUIRED when drifting is true.
|
||||
|
||||
## Frame
|
||||
` + frame + `
|
||||
|
||||
## Recent window titles (oldest to newest)
|
||||
` + strings.Join(recentTitles, "\n")
|
||||
}
|
||||
|
||||
type rawAmbientDrift struct {
|
||||
Drifting bool `json:"drifting"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields
|
||||
// "". A drift with no message degrades to "" (silence) rather than an error:
|
||||
// the ambient signal is advisory, so the safe degenerate is to say nothing —
|
||||
// exactly like parseNudge.
|
||||
func parseAmbientDrift(s string) (string, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", ErrEmptyResponse
|
||||
}
|
||||
if strings.IndexByte(s, '{') < 0 {
|
||||
return "", ErrNoJSON
|
||||
}
|
||||
jsonStr, err := extractJSON(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||
}
|
||||
var raw rawAmbientDrift
|
||||
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||
}
|
||||
if !raw.Drifting {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(raw.Message), nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// internal/ai/ambient_test.go
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServiceAmbientDriftSuccess(t *testing.T) {
|
||||
fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`}
|
||||
line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"})
|
||||
if err != nil {
|
||||
t.Fatalf("ambient drift: %v", err)
|
||||
}
|
||||
if line == "" {
|
||||
t.Fatal("expected a non-empty drift line")
|
||||
}
|
||||
if !strings.Contains(fb.gotPrompt, "ship keel") {
|
||||
t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt)
|
||||
}
|
||||
if !strings.Contains(fb.gotPrompt, "YouTube - Brave") {
|
||||
t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAmbientDriftBackendError(t *testing.T) {
|
||||
fb := &fakeBackend{err: errors.New("boom")}
|
||||
if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil {
|
||||
t.Fatal("want backend error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAmbientDrift(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{"on track yields empty", `{"drifting": false, "message": ""}`, "", nil},
|
||||
{"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil},
|
||||
{"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil},
|
||||
{"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil},
|
||||
{"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil},
|
||||
{"empty response", " ", "", ErrEmptyResponse},
|
||||
{"no json", "no braces here", "", ErrNoJSON},
|
||||
{"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseAmbientDrift(tt.in)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,73 @@
|
||||
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.
|
||||
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
|
||||
|
||||
## 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,50 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposePromptHasFollowUpRule(t *testing.T) {
|
||||
p := buildProposePrompt("brief")
|
||||
if !strings.Contains(p, "do not simply repeat") {
|
||||
t.Fatalf("prompt missing follow-up rule:\n%s", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// internal/ambient/sentinel.go
|
||||
|
||||
// Package ambient is Keel's always-on drift coach: a Sentinel that watches the
|
||||
// window stream beside the one-mode harness and, when no mode is active, judges
|
||||
// recent activity against the ~/owc frame and surfaces drift. It is not a mode —
|
||||
// it runs continuously, including when the harness is idle.
|
||||
package ambient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/frame"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/memory"
|
||||
"keel/internal/notify"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
// recentTitlesMax bounds the title ring (matches the focus mode's history cap).
|
||||
const recentTitlesMax = 10
|
||||
|
||||
// evalTimeout bounds a single ambient brain call.
|
||||
const evalTimeout = 30 * time.Second
|
||||
|
||||
// defaultCadence is used when the configured cadence is non-positive.
|
||||
const defaultCadence = 5 * time.Minute
|
||||
|
||||
// kindAmbientNudge is the memory event recorded for a sustained ambient drift.
|
||||
const kindAmbientNudge = "ambient_nudge"
|
||||
|
||||
// Ambient mode dial values (mirror settings.Ambient*).
|
||||
const (
|
||||
modeOff = "off"
|
||||
modeStatus = "status"
|
||||
modeNotify = "notify"
|
||||
)
|
||||
|
||||
// AmbientDriftJudge is the brain call the sentinel needs. *ai.Service satisfies
|
||||
// it; declaring it here keeps ambient decoupled from the ai package.
|
||||
type AmbientDriftJudge interface {
|
||||
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||
}
|
||||
|
||||
// Deps are the ports the sentinel depends on. Knowledge and Tasks may be nil
|
||||
// (frame.Assemble guards). Memory and Notifier may be nil (best-effort guards).
|
||||
// ActiveMode reports the harness's active mode kind ("" = idle); nil counts as
|
||||
// always-idle. Clock defaults to time.Now.
|
||||
type Deps struct {
|
||||
AI AmbientDriftJudge
|
||||
Knowledge knowledge.Source
|
||||
Tasks tasks.Provider
|
||||
Notifier notify.Notifier
|
||||
Memory memory.Store
|
||||
Clock func() time.Time
|
||||
ActiveMode func() string
|
||||
}
|
||||
|
||||
// Sentinel watches activity and surfaces ambient drift.
|
||||
type Sentinel struct {
|
||||
mu sync.Mutex
|
||||
deps Deps
|
||||
cadence time.Duration
|
||||
mode string
|
||||
|
||||
recent []string
|
||||
lastWindow evidence.WindowSnapshot
|
||||
line string
|
||||
lastEvalTitles string
|
||||
drifting bool
|
||||
committed bool
|
||||
snoozeUntil time.Time
|
||||
onChange []func()
|
||||
}
|
||||
|
||||
// New builds a sentinel. A non-positive cadence falls back to defaultCadence; an
|
||||
// empty mode falls back to notify.
|
||||
func New(d Deps, cadence time.Duration, mode string) *Sentinel {
|
||||
if d.Clock == nil {
|
||||
d.Clock = time.Now
|
||||
}
|
||||
if cadence <= 0 {
|
||||
cadence = defaultCadence
|
||||
}
|
||||
return &Sentinel{deps: d, cadence: cadence, mode: normalizeMode(mode)}
|
||||
}
|
||||
|
||||
func normalizeMode(m string) string {
|
||||
if m == "" {
|
||||
return modeNotify
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// OnWindow ingests one sensor observation: it records the latest window and
|
||||
// appends the title to the dedup-capped ring. Cheap and lock-bounded; it fires
|
||||
// no brain call.
|
||||
func (s *Sentinel) OnWindow(w evidence.WindowSnapshot) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastWindow = w
|
||||
t := strings.TrimSpace(w.Title)
|
||||
if t == "" {
|
||||
return
|
||||
}
|
||||
if n := len(s.recent); n > 0 && s.recent[n-1] == t {
|
||||
return
|
||||
}
|
||||
s.recent = append(s.recent, t)
|
||||
if len(s.recent) > recentTitlesMax {
|
||||
s.recent = s.recent[len(s.recent)-recentTitlesMax:]
|
||||
}
|
||||
}
|
||||
|
||||
// Line returns the current ambient coaching line ("" when on-track, quiet, or
|
||||
// snoozed) for the surfaces.
|
||||
func (s *Sentinel) Line() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.line
|
||||
}
|
||||
|
||||
// AddOnChange registers a surface refresh fired when the line changes.
|
||||
func (s *Sentinel) AddOnChange(f func()) {
|
||||
s.mu.Lock()
|
||||
s.onChange = append(s.onChange, f)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// fireOnChange notifies registered surfaces off the lock.
|
||||
func (s *Sentinel) fireOnChange() {
|
||||
s.mu.Lock()
|
||||
fs := append([]func(){}, s.onChange...)
|
||||
s.mu.Unlock()
|
||||
for _, f := range fs {
|
||||
if f != nil {
|
||||
f()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recentForTest returns a copy of the ring (test-only).
|
||||
func (s *Sentinel) recentForTest() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.recent...)
|
||||
}
|
||||
|
||||
// Run drives the cadence loop: every cadence it evaluates, until ctx is
|
||||
// cancelled. It re-reads the cadence each cycle so SetConfig takes effect on the
|
||||
// next tick.
|
||||
func (s *Sentinel) Run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(s.currentCadence()):
|
||||
s.evaluate(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) currentCadence() time.Duration {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.cadence
|
||||
}
|
||||
|
||||
// SetConfig updates the cadence and mode live (next tick).
|
||||
func (s *Sentinel) SetConfig(cadence time.Duration, mode string) {
|
||||
s.mu.Lock()
|
||||
if cadence > 0 {
|
||||
s.cadence = cadence
|
||||
}
|
||||
s.mode = normalizeMode(mode)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Snooze mutes the sentinel for d: it clears the current line and episode and
|
||||
// suppresses evaluation until the window elapses.
|
||||
func (s *Sentinel) Snooze(d time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.snoozeUntil = s.deps.Clock().Add(d)
|
||||
changed := s.line != ""
|
||||
s.line = ""
|
||||
s.drifting = false
|
||||
s.committed = false
|
||||
s.mu.Unlock()
|
||||
if changed {
|
||||
s.fireOnChange()
|
||||
}
|
||||
}
|
||||
|
||||
// evaluate runs one decision cycle. See the spec's "Trigger discipline."
|
||||
func (s *Sentinel) evaluate(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
if s.mode == modeOff {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if s.deps.Clock().Before(s.snoozeUntil) {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if s.activeModeLocked() != "" {
|
||||
// A mode is coaching; stay silent and clear any stale line.
|
||||
changed := s.clearLocked()
|
||||
s.mu.Unlock()
|
||||
if changed {
|
||||
s.fireOnChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
titles := append([]string(nil), s.recent...)
|
||||
joined := strings.Join(titles, "\n")
|
||||
// Cheap gate: nothing new to judge and we are on-track -> skip the brain call.
|
||||
if joined == s.lastEvalTitles && s.line == "" {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
win := s.lastWindow
|
||||
mode := s.mode
|
||||
judge := s.deps.AI
|
||||
s.mu.Unlock()
|
||||
|
||||
if judge == nil {
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, evalTimeout)
|
||||
fr := frame.Assemble(cctx, s.deps.Knowledge, s.deps.Tasks)
|
||||
msg, err := judge.AmbientDrift(cctx, fr, titles)
|
||||
cancel()
|
||||
|
||||
s.mu.Lock()
|
||||
// A mode may have activated during the (off-lock) brain call; re-check so a
|
||||
// result computed while idle is not surfaced over an now-active mode.
|
||||
if s.activeModeLocked() != "" {
|
||||
changed := s.clearLocked()
|
||||
s.mu.Unlock()
|
||||
if changed {
|
||||
s.fireOnChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
s.lastEvalTitles = joined
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
log.Printf("ambient: drift judge failed: %v", err)
|
||||
return // never fabricate drift; leave the prior line intact
|
||||
}
|
||||
|
||||
if msg == "" { // on-track
|
||||
changed := s.clearLocked()
|
||||
s.mu.Unlock()
|
||||
if changed {
|
||||
s.fireOnChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !s.drifting { // new episode: show the line, no toast yet
|
||||
s.line = msg
|
||||
s.drifting = true
|
||||
s.committed = false
|
||||
s.mu.Unlock()
|
||||
s.fireOnChange()
|
||||
return
|
||||
}
|
||||
|
||||
// Sustained drift. Commit once: record memory, and toast in notify mode.
|
||||
changed := s.line != msg
|
||||
s.line = msg
|
||||
if s.committed {
|
||||
s.mu.Unlock()
|
||||
if changed {
|
||||
s.fireOnChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
s.committed = true
|
||||
now := s.deps.Clock()
|
||||
mem := s.deps.Memory
|
||||
notifier := s.deps.Notifier
|
||||
s.mu.Unlock()
|
||||
|
||||
if mem != nil {
|
||||
ev := memory.Event{Kind: kindAmbientNudge, At: now, Data: map[string]any{
|
||||
"message": msg, "title": win.Title, "class": win.Class,
|
||||
}}
|
||||
if e := mem.Record(ctx, ev); e != nil {
|
||||
log.Printf("ambient: memory record: %v", e)
|
||||
}
|
||||
}
|
||||
if mode == modeNotify && notifier != nil {
|
||||
if e := notifier.Notify(ctx, "Keel — drift", msg); e != nil {
|
||||
log.Printf("ambient: notify: %v", e)
|
||||
}
|
||||
}
|
||||
s.fireOnChange()
|
||||
}
|
||||
|
||||
// clearLocked resets line + episode state and reports whether the line changed.
|
||||
// Caller holds mu.
|
||||
func (s *Sentinel) clearLocked() bool {
|
||||
changed := s.line != ""
|
||||
s.line = ""
|
||||
s.drifting = false
|
||||
s.committed = false
|
||||
return changed
|
||||
}
|
||||
|
||||
// activeModeLocked reads the active-mode kind, treating a nil hook as idle.
|
||||
// Caller holds mu (the hook does no locking on the sentinel).
|
||||
func (s *Sentinel) activeModeLocked() string {
|
||||
if s.deps.ActiveMode == nil {
|
||||
return ""
|
||||
}
|
||||
return s.deps.ActiveMode()
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// internal/ambient/sentinel_test.go
|
||||
package ambient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/memory"
|
||||
)
|
||||
|
||||
func snap(title, class string) evidence.WindowSnapshot {
|
||||
return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}}
|
||||
}
|
||||
|
||||
func TestNewLineEmpty(t *testing.T) {
|
||||
s := New(Deps{}, 0, "notify")
|
||||
if s.Line() != "" {
|
||||
t.Fatalf("fresh sentinel line = %q, want empty", s.Line())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnWindowRingDedupesAndCaps(t *testing.T) {
|
||||
s := New(Deps{}, 0, "notify")
|
||||
s.OnWindow(snap("a", "x"))
|
||||
s.OnWindow(snap("a", "x")) // consecutive dup ignored
|
||||
s.OnWindow(snap("b", "x"))
|
||||
if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("ring = %v, want [a b]", got)
|
||||
}
|
||||
for i := 0; i < recentTitlesMax+5; i++ {
|
||||
s.OnWindow(snap(string(rune('A'+i)), "x"))
|
||||
}
|
||||
if got := s.recentForTest(); len(got) != recentTitlesMax {
|
||||
t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnWindowSkipsEmptyTitle(t *testing.T) {
|
||||
s := New(Deps{}, 0, "notify")
|
||||
s.OnWindow(snap("", "x"))
|
||||
if got := s.recentForTest(); len(got) != 0 {
|
||||
t.Fatalf("empty title must not enter ring, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = context.Background
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type fakeJudge struct {
|
||||
out []string // queued returns, consumed front-to-back
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
if len(f.out) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
r := f.out[0]
|
||||
f.out = f.out[1:]
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type fakeNotifier struct {
|
||||
calls int
|
||||
body string
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) Notify(_ context.Context, _, body string) error {
|
||||
f.calls++
|
||||
f.body = body
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel {
|
||||
return New(Deps{
|
||||
AI: j,
|
||||
Notifier: n,
|
||||
Memory: mem,
|
||||
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
|
||||
ActiveMode: func() string { return *active },
|
||||
}, time.Minute, mode)
|
||||
}
|
||||
|
||||
// memoryFake is a minimal memory.Store recorder.
|
||||
type memoryFake struct {
|
||||
events []memory.Event
|
||||
}
|
||||
|
||||
func (m *memoryFake) Record(_ context.Context, e memory.Event) error {
|
||||
m.events = append(m.events, e)
|
||||
return nil
|
||||
}
|
||||
func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil }
|
||||
|
||||
// --- behavioural matrix ---
|
||||
|
||||
func TestEvaluateOnTrackIsSilent(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{""}}
|
||||
n := &fakeNotifier{}
|
||||
mem := &memoryFake{}
|
||||
active := ""
|
||||
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||
s.OnWindow(snap("main.go - code", "code"))
|
||||
s.evaluate(context.Background())
|
||||
if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 {
|
||||
t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{"deep in YouTube"}}
|
||||
n := &fakeNotifier{}
|
||||
mem := &memoryFake{}
|
||||
active := ""
|
||||
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
s.evaluate(context.Background())
|
||||
if s.Line() == "" {
|
||||
t.Fatal("first drift must set the status line")
|
||||
}
|
||||
if n.calls != 0 {
|
||||
t.Fatalf("first drift must NOT toast, got %d", n.calls)
|
||||
}
|
||||
if len(mem.events) != 0 {
|
||||
t.Fatalf("first drift must not record memory yet, got %d", len(mem.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}}
|
||||
n := &fakeNotifier{}
|
||||
mem := &memoryFake{}
|
||||
active := ""
|
||||
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
|
||||
s.evaluate(context.Background()) // first: line, no toast
|
||||
s.evaluate(context.Background()) // second: sustained -> one toast + one memory
|
||||
s.evaluate(context.Background()) // third: still drift -> NO second toast
|
||||
|
||||
if n.calls != 1 {
|
||||
t.Fatalf("sustained drift must toast exactly once, got %d", n.calls)
|
||||
}
|
||||
if len(mem.events) != 1 {
|
||||
t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events))
|
||||
}
|
||||
if mem.events[0].Kind != kindAmbientNudge {
|
||||
t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{"drift", "drift", ""}}
|
||||
n := &fakeNotifier{}
|
||||
active := ""
|
||||
s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
s.evaluate(context.Background())
|
||||
s.evaluate(context.Background())
|
||||
s.evaluate(context.Background()) // on-track now
|
||||
if s.Line() != "" {
|
||||
t.Fatalf("return to on-track must clear the line, got %q", s.Line())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateSilentWhenModeActive(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{"drift"}}
|
||||
active := "focus"
|
||||
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
s.evaluate(context.Background())
|
||||
if j.calls != 0 {
|
||||
t.Fatalf("an active mode must suppress the brain call, got %d", j.calls)
|
||||
}
|
||||
if s.Line() != "" {
|
||||
t.Fatalf("an active mode must keep the line empty, got %q", s.Line())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateStatusModeNeverToasts(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{"drift", "drift"}}
|
||||
n := &fakeNotifier{}
|
||||
mem := &memoryFake{}
|
||||
active := ""
|
||||
s := newTestSentinel(j, n, mem, modeStatus, &active)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
s.evaluate(context.Background())
|
||||
s.evaluate(context.Background())
|
||||
if n.calls != 0 {
|
||||
t.Fatalf("status mode must never toast, got %d", n.calls)
|
||||
}
|
||||
if s.Line() == "" {
|
||||
t.Fatal("status mode must still show the line")
|
||||
}
|
||||
if len(mem.events) != 1 {
|
||||
t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateOffModeDoesNothing(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{"drift"}}
|
||||
active := ""
|
||||
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
s.evaluate(context.Background())
|
||||
if j.calls != 0 {
|
||||
t.Fatalf("off mode must not call the brain, got %d", j.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{""}} // one on-track answer available
|
||||
active := ""
|
||||
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||
s.OnWindow(snap("main.go - code", "code"))
|
||||
s.evaluate(context.Background()) // calls=1, on-track
|
||||
s.evaluate(context.Background()) // unchanged ring + on-track -> skip
|
||||
if j.calls != 1 {
|
||||
t.Fatalf("cheap gate must skip the second call, got %d", j.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// flippingJudge simulates a harness mode activating DURING the brain call: it
|
||||
// returns a drift verdict but flips the active-mode flag as a side effect, so by
|
||||
// the time evaluate re-acquires the lock a mode is active.
|
||||
type flippingJudge struct{ active *string }
|
||||
|
||||
func (f *flippingJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
|
||||
*f.active = "focus"
|
||||
return "deep in YouTube", nil
|
||||
}
|
||||
|
||||
// A mode activating mid-evaluation must suppress the surface: no line, no toast,
|
||||
// no memory record (coexistence rule holds across the unlock-for-I/O window).
|
||||
func TestEvaluateModeActivatingDuringJudgeStaysSilent(t *testing.T) {
|
||||
active := ""
|
||||
n := &fakeNotifier{}
|
||||
mem := &memoryFake{}
|
||||
s := New(Deps{
|
||||
AI: &flippingJudge{active: &active},
|
||||
Notifier: n,
|
||||
Memory: mem,
|
||||
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
|
||||
ActiveMode: func() string { return active },
|
||||
}, time.Minute, modeNotify)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
s.evaluate(context.Background())
|
||||
if s.Line() != "" {
|
||||
t.Fatalf("mode active after judge must leave line empty, got %q", s.Line())
|
||||
}
|
||||
if n.calls != 0 {
|
||||
t.Fatalf("mode active after judge must not toast, got %d", n.calls)
|
||||
}
|
||||
if len(mem.events) != 0 {
|
||||
t.Fatalf("mode active after judge must not record memory, got %d", len(mem.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnoozeMutesAndClears(t *testing.T) {
|
||||
j := &fakeJudge{out: []string{"drift", "drift"}}
|
||||
active := ""
|
||||
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||
s.evaluate(context.Background()) // line set
|
||||
s.Snooze(time.Hour)
|
||||
if s.Line() != "" {
|
||||
t.Fatalf("snooze must clear the line, got %q", s.Line())
|
||||
}
|
||||
callsBefore := j.calls
|
||||
s.evaluate(context.Background()) // snoozed -> no call
|
||||
if j.calls != callsBefore {
|
||||
t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package aw is a thin ActivityWatch REST client: it creates buckets and reads
|
||||
// and writes events. It holds no Keel concepts, so it stays a leaf package.
|
||||
package aw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete
|
||||
// derived events it is 0.
|
||||
type Event struct {
|
||||
Timestamp time.Time
|
||||
Duration float64
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
// Client talks to an aw-server over its /api/0 REST surface.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// New builds a client for the given base URL (e.g. http://localhost:5600).
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
hc: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists
|
||||
// response (304) is success.
|
||||
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client": client,
|
||||
"type": eventType,
|
||||
"hostname": hostname(),
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "keel"
|
||||
}
|
||||
|
||||
type eventJSON struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Duration float64 `json:"duration"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// Insert posts a single event to the bucket. The AW events endpoint takes a
|
||||
// JSON array, so the event is wrapped in a one-element list.
|
||||
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error {
|
||||
body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Recent returns up to limit events from the bucket, newest first (the AW
|
||||
// default ordering for the events endpoint).
|
||||
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode)
|
||||
}
|
||||
var raw []eventJSON
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Event, len(raw))
|
||||
for i, e := range raw {
|
||||
out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package aw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnsureBucketPostsCreate(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL)
|
||||
if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||
t.Fatalf("EnsureBucket: %v", err)
|
||||
}
|
||||
if gotPath != "/api/0/buckets/keel.events" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" {
|
||||
t.Fatalf("body = %+v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBucketTreatsExistsAsOK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists
|
||||
}))
|
||||
defer srv.Close()
|
||||
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||
t.Fatalf("existing bucket should be OK, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBucketErrorsOnServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil {
|
||||
t.Fatal("expected error on 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertPostsEventArray(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotEvents []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotEvents)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL)
|
||||
ev := Event{Timestamp: time.Unix(1000, 0).UTC(), Data: map[string]any{"_kind": "proposal_made"}}
|
||||
if err := c.Insert(context.Background(), "keel.events", ev); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
if gotPath != "/api/0/buckets/keel.events/events" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if len(gotEvents) != 1 || gotEvents[0]["data"].(map[string]any)["_kind"] != "proposal_made" {
|
||||
t.Fatalf("events = %+v", gotEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentDecodesNewestFirst(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", r.Method)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "5" {
|
||||
t.Errorf("limit = %q, want 5", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, `[
|
||||
{"id":2,"timestamp":"2026-06-05T10:00:00+00:00","duration":0,"data":{"_kind":"proposal_made"}},
|
||||
{"id":1,"timestamp":"2026-06-04T10:00:00+00:00","duration":0,"data":{"_kind":"action_taken"}}
|
||||
]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := New(srv.URL).Recent(context.Background(), "keel.events", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Recent: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Data["_kind"] != "proposal_made" {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,8 +24,9 @@ type WindowSnapshot struct {
|
||||
}
|
||||
|
||||
// Source is the activity port. Watch runs until ctx is cancelled, invoking
|
||||
// onChange on every active-window change, and once immediately with the
|
||||
// current window.
|
||||
// onChange once immediately with the current window and then on every change of
|
||||
// the active window or its title (a tab switch changes the title but not the
|
||||
// active window).
|
||||
type Source interface {
|
||||
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pollLoop samples the active window via read and forwards snapshots through
|
||||
// onChange, but only when the observation changes — a different window OR a
|
||||
// different title. The title case is the one that matters: switching a browser
|
||||
// tab changes the window title without changing the active window, so a sensor
|
||||
// that re-reads only on active-window events never sees it and credits the stale
|
||||
// title. Polling re-reads on a fixed cadence, so a tab switch is caught within
|
||||
// one interval.
|
||||
//
|
||||
// It emits once immediately, then samples every interval, until ctx is
|
||||
// cancelled. WindowSnapshot is comparable, so the change check is a plain
|
||||
// equality. Factoring the loop here keeps the change-dedup identical across
|
||||
// sensors and lets it be tested without a display. (The Windows sensor predates
|
||||
// this and keeps its own equivalent loop.)
|
||||
func pollLoop(ctx context.Context, interval time.Duration, read func() WindowSnapshot, onChange func(WindowSnapshot)) {
|
||||
var last WindowSnapshot
|
||||
var haveLast bool
|
||||
emit := func() {
|
||||
s := read()
|
||||
if haveLast && s == last {
|
||||
return
|
||||
}
|
||||
last, haveLast = s, true
|
||||
onChange(s)
|
||||
}
|
||||
|
||||
emit() // immediate current window
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
emit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug
|
||||
// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and
|
||||
// so missed a browser tab switch — same window class, new title — crediting all
|
||||
// the time to the stale title. The poll loop must emit when only the title
|
||||
// changes.
|
||||
func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) {
|
||||
// Same window (class) throughout; the title changes mid-stream, like a tab
|
||||
// switch. The repeated identical reads must NOT re-emit; the title change must.
|
||||
titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"}
|
||||
var i int
|
||||
read := func() WindowSnapshot {
|
||||
idx := i
|
||||
if idx >= len(titles) {
|
||||
idx = len(titles) - 1
|
||||
}
|
||||
i++
|
||||
return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}}
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []WindowSnapshot
|
||||
onChange := func(s WindowSnapshot) {
|
||||
mu.Lock()
|
||||
got = append(got, s)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(got)
|
||||
mu.Unlock()
|
||||
if n >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatalf("expected >=2 emits (one per distinct title), got %d", n)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if got[0].Title != "Keel - Brave" {
|
||||
t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave")
|
||||
}
|
||||
if got[1].Title != "Consume - Brave" {
|
||||
t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is
|
||||
// not re-emitted, so redundant samples never churn the consumer.
|
||||
func TestPollLoopDedupesIdenticalObservations(t *testing.T) {
|
||||
read := func() WindowSnapshot {
|
||||
return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}}
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var n int
|
||||
onChange := func(WindowSnapshot) {
|
||||
mu.Lock()
|
||||
n++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("identical observations must emit once, got %d emits", n)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"antidrift/internal/winapi"
|
||||
"keel/internal/winapi"
|
||||
)
|
||||
|
||||
// pollInterval is how often the Windows sensor samples the foreground window.
|
||||
|
||||
+15
-38
@@ -4,24 +4,29 @@ package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jezek/xgb/xproto"
|
||||
"github.com/jezek/xgbutil"
|
||||
"github.com/jezek/xgbutil/ewmh"
|
||||
"github.com/jezek/xgbutil/icccm"
|
||||
"github.com/jezek/xgbutil/xevent"
|
||||
"github.com/jezek/xgbutil/xprop"
|
||||
"github.com/jezek/xgbutil/xwindow"
|
||||
)
|
||||
|
||||
// pollInterval is how often the X11 sensor samples the active window. ~1s
|
||||
// latency on a switch is immaterial for a focus tracker, and polling is what
|
||||
// catches a title change inside the same window — e.g. switching a browser tab,
|
||||
// which changes the title but not the active window, so an _NET_ACTIVE_WINDOW
|
||||
// event subscription would miss it and credit the stale title. Mirrors the
|
||||
// Windows sensor's polling cadence.
|
||||
const pollInterval = 750 * time.Millisecond
|
||||
|
||||
// NewSource returns the real X11 active-window sensor.
|
||||
func NewSource() Source { return &x11Source{} }
|
||||
|
||||
type x11Source struct{}
|
||||
|
||||
// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW
|
||||
// changes on the root window, and emits a snapshot immediately plus on every
|
||||
// change. Any failure degrades to an Unavailable snapshot; it never panics the
|
||||
// Watch opens one long-lived X connection and samples the active window every
|
||||
// pollInterval, emitting a snapshot whenever the active window OR its title
|
||||
// changes. Any failure degrades to an Unavailable snapshot; it never panics the
|
||||
// daemon.
|
||||
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||
X, err := xgbutil.NewConn()
|
||||
@@ -32,38 +37,11 @@ func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||
}
|
||||
defer X.Conn().Close()
|
||||
|
||||
root := X.RootWin()
|
||||
activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW")
|
||||
if err != nil {
|
||||
onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error()))
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for property changes on the root window.
|
||||
if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil {
|
||||
onChange(unavailable("cannot listen on root window: " + err.Error()))
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
|
||||
emit := func() { onChange(snapshot(X)) }
|
||||
emit() // immediate current window
|
||||
|
||||
xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) {
|
||||
if ev.Atom == activeAtom {
|
||||
emit()
|
||||
}
|
||||
}).Connect(X, root)
|
||||
|
||||
// Run the event loop until ctx is cancelled.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
xevent.Quit(X)
|
||||
}()
|
||||
xevent.Main(X)
|
||||
pollLoop(ctx, pollInterval, func() WindowSnapshot { return snapshot(X) }, onChange)
|
||||
}
|
||||
|
||||
// snapshot reads the current active window's title and class. A missing active
|
||||
// window (or read error) yields an Unavailable snapshot.
|
||||
func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
||||
active, err := ewmh.ActiveWindowGet(X)
|
||||
if err != nil || active == 0 {
|
||||
@@ -86,4 +64,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
||||
Health: EvidenceHealth{Available: true},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// internal/frame/frame.go
|
||||
|
||||
// Package frame assembles the standing "what matters to Felix" brief — today's
|
||||
// tasks plus the ~/owc goals and life-domain notes — that grounds a brain call.
|
||||
// It is shared by the off-screen mode (which adds proposal history) and the
|
||||
// ambient sentinel. Best-effort throughout: it never errors and tolerates nil
|
||||
// ports and missing files.
|
||||
package frame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
// goalsPath is the standing-goals note loaded for the brief.
|
||||
const goalsPath = "~/owc/goals-2026.md"
|
||||
|
||||
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
|
||||
const bugGlob = "bug-*.md"
|
||||
|
||||
// MaxBriefBytes caps the assembled brief so the prompt stays bounded.
|
||||
const MaxBriefBytes = 8 * 1024
|
||||
|
||||
// Assemble builds the frame brief from today's tasks, the standing goals note,
|
||||
// and the life-domain notes. It never panics and never errors; nil ports and
|
||||
// missing files degrade to a short, mostly-empty brief.
|
||||
func Assemble(ctx context.Context, know knowledge.Source, tasksProvider tasks.Provider) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("## Today's tasks\n")
|
||||
b.WriteString(briefTasks(ctx, tasksProvider))
|
||||
b.WriteString("\n")
|
||||
|
||||
if goals := briefGoals(ctx, know); goals != "" {
|
||||
b.WriteString("## Goals\n")
|
||||
b.WriteString(goals)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if domains := briefLifeDomains(ctx, know); domains != "" {
|
||||
b.WriteString("## Life domains\n")
|
||||
b.WriteString(domains)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return Truncate(b.String(), MaxBriefBytes)
|
||||
}
|
||||
|
||||
// briefTasks lists today's task titles, or "(none)" when the port is absent,
|
||||
// errors, or returns nothing.
|
||||
func briefTasks(ctx context.Context, tp tasks.Provider) string {
|
||||
if tp == nil {
|
||||
return "(none)\n"
|
||||
}
|
||||
list, err := tp.Today(ctx)
|
||||
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 briefGoals(ctx context.Context, know knowledge.Source) string {
|
||||
if know == nil {
|
||||
return ""
|
||||
}
|
||||
p, err := know.Load(ctx, 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. Returns ""
|
||||
// when the port is absent or no notes are readable.
|
||||
func briefLifeDomains(ctx context.Context, know knowledge.Source) string {
|
||||
if 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 := know.Load(ctx, 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")
|
||||
}
|
||||
|
||||
// Truncate clips s to at most max bytes with a marker, backing up to a rune
|
||||
// boundary so it never splits a multibyte rune.
|
||||
func Truncate(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,70 @@
|
||||
// internal/frame/frame_test.go
|
||||
package frame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
// fakeTasks is a tasks.Provider returning a fixed list.
|
||||
type fakeTasks struct{ list []tasks.Task }
|
||||
|
||||
// emptyKnowledge is a knowledge.Source that returns no text for any path, so
|
||||
// Assemble emits neither a Goals nor a Life-domains section regardless of what
|
||||
// files exist on the host.
|
||||
type emptyKnowledge struct{}
|
||||
|
||||
func (emptyKnowledge) Load(context.Context, string) (knowledge.Profile, error) {
|
||||
return knowledge.Profile{}, nil
|
||||
}
|
||||
|
||||
func (f fakeTasks) Today(context.Context) ([]tasks.Task, error) { return f.list, nil }
|
||||
func (f fakeTasks) Create(context.Context, tasks.Task) error { return nil }
|
||||
|
||||
func TestAssembleListsTodaysTasks(t *testing.T) {
|
||||
tp := fakeTasks{list: []tasks.Task{{Title: "write the keel spec"}}}
|
||||
got := Assemble(context.Background(), nil, tp)
|
||||
if !strings.Contains(got, "## Today's tasks") {
|
||||
t.Fatalf("missing tasks header:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "write the keel spec") {
|
||||
t.Fatalf("missing task title:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no knowledge source and no tasks, Assemble degrades to a short brief and
|
||||
// never panics — both ports may be nil.
|
||||
func TestAssembleToleratesNilPorts(t *testing.T) {
|
||||
got := Assemble(context.Background(), nil, nil)
|
||||
if !strings.Contains(got, "## Today's tasks") {
|
||||
t.Fatalf("expected a tasks header even with no ports:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "(none)") {
|
||||
t.Fatalf("expected (none) for absent tasks:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleWithEmptyKnowledge(t *testing.T) {
|
||||
got := Assemble(context.Background(), emptyKnowledge{}, fakeTasks{})
|
||||
if strings.Contains(got, "## Goals") {
|
||||
t.Fatalf("empty knowledge should not emit a Goals section:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "## Life domains") {
|
||||
t.Fatalf("empty knowledge should not emit a Life domains section:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateClipsWithMarker(t *testing.T) {
|
||||
in := strings.Repeat("x", MaxBriefBytes+100)
|
||||
got := Truncate(in, MaxBriefBytes)
|
||||
if len(got) > MaxBriefBytes+len("\n…(truncated)") {
|
||||
t.Fatalf("truncate did not clip: len=%d", len(got))
|
||||
}
|
||||
if !strings.HasSuffix(got, "(truncated)") {
|
||||
t.Fatalf("missing truncation marker: %q", got[len(got)-20:])
|
||||
}
|
||||
}
|
||||
@@ -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,27 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/enforce"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/memory"
|
||||
"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). Memory is Keel's durable cross-run event
|
||||
// store (AW-backed, or a nop when AW is down).
|
||||
type Services struct {
|
||||
AI *ai.Service
|
||||
Tasks tasks.Provider
|
||||
Knowledge knowledge.Source
|
||||
Enforce enforce.Guard
|
||||
Memory memory.Store
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
|
||||
// events and read them back with Recent; the AW-backed adapter persists them to
|
||||
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
|
||||
// them — this package stays a generic event store.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keel/internal/aw"
|
||||
)
|
||||
|
||||
// Event is one derived event Keel chooses to remember. Kind is the event type
|
||||
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
|
||||
// other tools' source of truth.
|
||||
type Event struct {
|
||||
Kind string
|
||||
At time.Time
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
// Store records derived events and reads recent ones back by kind.
|
||||
type Store interface {
|
||||
Record(ctx context.Context, e Event) error
|
||||
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||
}
|
||||
|
||||
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
|
||||
type nopStore struct{}
|
||||
|
||||
// NewNop returns a Store that drops writes and returns no history.
|
||||
func NewNop() Store { return nopStore{} }
|
||||
|
||||
func (nopStore) Record(context.Context, Event) error { return nil }
|
||||
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
|
||||
|
||||
// Fake is an in-memory Store for tests.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
events []Event
|
||||
}
|
||||
|
||||
// NewFake returns an empty in-memory Store.
|
||||
func NewFake() *Fake { return &Fake{} }
|
||||
|
||||
func (f *Fake) Record(_ context.Context, e Event) error {
|
||||
f.mu.Lock()
|
||||
f.events = append(f.events, e)
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []Event
|
||||
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
|
||||
if f.events[i].Kind == kind {
|
||||
out = append(out, f.events[i])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Events returns a snapshot of everything recorded, for test assertions.
|
||||
func (f *Fake) Events() []Event {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]Event(nil), f.events...)
|
||||
}
|
||||
|
||||
// overfetch bounds how many recent bucket events awStore pulls before filtering
|
||||
// by kind, because keel.events interleaves all kinds. Events older than this
|
||||
// window are not seen — acceptable for "recent".
|
||||
const overfetch = 200
|
||||
|
||||
// kindKey is the data field carrying the Event.Kind inside an AW event.
|
||||
const kindKey = "_kind"
|
||||
|
||||
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
|
||||
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
|
||||
type AWClient interface {
|
||||
Insert(ctx context.Context, bucketID string, e aw.Event) error
|
||||
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
|
||||
}
|
||||
|
||||
type awStore struct {
|
||||
c AWClient
|
||||
bucketID string
|
||||
}
|
||||
|
||||
// NewAWStore returns a Store that persists events to the given AW bucket.
|
||||
func NewAWStore(c AWClient, bucketID string) Store {
|
||||
return &awStore{c: c, bucketID: bucketID}
|
||||
}
|
||||
|
||||
func (s *awStore) Record(ctx context.Context, e Event) error {
|
||||
data := make(map[string]any, len(e.Data)+1)
|
||||
for k, v := range e.Data {
|
||||
data[k] = v
|
||||
}
|
||||
data[kindKey] = e.Kind
|
||||
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
|
||||
}
|
||||
|
||||
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Event
|
||||
for _, ev := range raw {
|
||||
if len(out) >= n {
|
||||
break
|
||||
}
|
||||
if k, _ := ev.Data[kindKey].(string); k == kind {
|
||||
data := make(map[string]any, len(ev.Data))
|
||||
for dk, dv := range ev.Data {
|
||||
if dk != kindKey {
|
||||
data[dk] = dv
|
||||
}
|
||||
}
|
||||
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: data})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keel/internal/aw"
|
||||
)
|
||||
|
||||
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
|
||||
f := NewFake()
|
||||
ctx := context.Background()
|
||||
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
|
||||
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
|
||||
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
|
||||
|
||||
got, err := f.Recent(ctx, "proposal_made", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Recent: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Data["n"] != "b" {
|
||||
t.Fatalf("want newest-first [b,a], got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRecentRespectsLimit(t *testing.T) {
|
||||
f := NewFake()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 4; i++ {
|
||||
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
|
||||
}
|
||||
got, _ := f.Recent(ctx, "proposal_made", 2)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("limit not honored: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNopStoreIsInert(t *testing.T) {
|
||||
s := NewNop()
|
||||
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
|
||||
t.Fatalf("nop Record: %v", err)
|
||||
}
|
||||
got, err := s.Recent(context.Background(), "x", 5)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("nop Recent = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
|
||||
// recent list so we can test mapping and over-fetch filtering without HTTP.
|
||||
type fakeAWClient struct {
|
||||
inserted []aw.Event
|
||||
recent []aw.Event // newest-first, mixed kinds
|
||||
lastLimit int
|
||||
}
|
||||
|
||||
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
|
||||
f.inserted = append(f.inserted, e)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
|
||||
f.lastLimit = limit
|
||||
return f.recent, nil
|
||||
}
|
||||
|
||||
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
|
||||
fc := &fakeAWClient{}
|
||||
s := NewAWStore(fc, "keel.events")
|
||||
at := time.Unix(1000, 0).UTC()
|
||||
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
if len(fc.inserted) != 1 {
|
||||
t.Fatalf("inserted %d", len(fc.inserted))
|
||||
}
|
||||
got := fc.inserted[0]
|
||||
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
|
||||
t.Fatalf("mapped event = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
|
||||
fc := &fakeAWClient{recent: []aw.Event{
|
||||
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
|
||||
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
|
||||
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
|
||||
}}
|
||||
s := NewAWStore(fc, "keel.events")
|
||||
got, err := s.Recent(context.Background(), "proposal_made", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Recent: %v", err)
|
||||
}
|
||||
if fc.lastLimit != overfetch {
|
||||
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
|
||||
}
|
||||
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
|
||||
t.Fatalf("filtered = %+v", got)
|
||||
}
|
||||
if _, leaked := got[0].Data["_kind"]; leaked {
|
||||
t.Fatalf("_kind leaked into returned Data: %+v", got[0].Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWStoreRecentCapsAtN(t *testing.T) {
|
||||
var recent []aw.Event
|
||||
for i := 0; i < 5; i++ {
|
||||
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
|
||||
}
|
||||
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
|
||||
got, _ := s.Recent(context.Background(), "proposal_made", 2)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("cap not applied: %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -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,104 @@
|
||||
// internal/mode/offscreen/collect.go
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"keel/internal/frame"
|
||||
)
|
||||
|
||||
const (
|
||||
recentProposals = 5 // how many recent proposals to show
|
||||
outcomeScan = 50 // how many outcome events to scan for correlation
|
||||
recentWindow = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// assembleBrief builds the off-screen prompt context: the shared frame brief
|
||||
// (today's tasks + ~/owc goals and life-domains) plus off-screen's own recent
|
||||
// proposal history. Best-effort: never panics, never errors, tolerates nil
|
||||
// ports and missing files.
|
||||
func (m *Mode) assembleBrief() string {
|
||||
base := frame.Assemble(context.Background(), m.know, m.tasks)
|
||||
|
||||
hist := m.briefHistory()
|
||||
if hist == "" {
|
||||
return base
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(base)
|
||||
if !strings.HasSuffix(base, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n## Recently proposed\n")
|
||||
b.WriteString(hist)
|
||||
return frame.Truncate(b.String(), frame.MaxBriefBytes)
|
||||
}
|
||||
|
||||
// briefHistory renders recent proposals with their outcome, newest first and
|
||||
// within recentWindow. Returns "" when there is no memory or no recent history.
|
||||
func (m *Mode) briefHistory() string {
|
||||
if m.mem == nil {
|
||||
return ""
|
||||
}
|
||||
ctx := context.Background()
|
||||
proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals)
|
||||
if err != nil || len(proposals) == 0 {
|
||||
return ""
|
||||
}
|
||||
taken := m.idSet(ctx, kindActionTaken)
|
||||
dismissed := m.idSet(ctx, kindProposalDismissed)
|
||||
now := m.now()
|
||||
|
||||
var b strings.Builder
|
||||
for _, ev := range proposals {
|
||||
if now.Sub(ev.At) > recentWindow {
|
||||
continue
|
||||
}
|
||||
id, _ := ev.Data["proposal_id"].(string)
|
||||
action, _ := ev.Data["next_action"].(string)
|
||||
if action == "" {
|
||||
continue
|
||||
}
|
||||
outcome := "unactioned"
|
||||
if taken[id] {
|
||||
outcome = "confirmed"
|
||||
} else if dismissed[id] {
|
||||
outcome = "dismissed"
|
||||
}
|
||||
fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// idSet reads recent events of a kind and returns their proposal_ids as a set.
|
||||
func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
evs, err := m.mem.Recent(ctx, kind, outcomeScan)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, e := range evs {
|
||||
if id, _ := e.Data["proposal_id"].(string); id != "" {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// relativeAge renders a coarse human age for a duration.
|
||||
func relativeAge(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// 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"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/memory"
|
||||
"keel/internal/mode"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
const proposeTimeout = 60 * time.Second
|
||||
|
||||
const (
|
||||
statusPending = "pending"
|
||||
statusProposed = "proposed"
|
||||
statusError = "error"
|
||||
statusDone = "done"
|
||||
)
|
||||
|
||||
const (
|
||||
kindProposalMade = "proposal_made"
|
||||
kindActionTaken = "action_taken"
|
||||
kindProposalDismissed = "proposal_dismissed"
|
||||
)
|
||||
|
||||
// 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
|
||||
mem memory.Store // may be nil; record helpers guard
|
||||
now func() time.Time // event timestamps; never nil after New
|
||||
|
||||
status string
|
||||
gen int
|
||||
proposal *ai.OffscreenProposal
|
||||
errMsg string
|
||||
done bool
|
||||
proposalID string
|
||||
}
|
||||
|
||||
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 {
|
||||
now := svc.Clock
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
m := &Mode{
|
||||
ai: svc.AI,
|
||||
tasks: svc.Tasks,
|
||||
know: svc.Knowledge,
|
||||
mem: svc.Memory,
|
||||
now: now,
|
||||
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()
|
||||
id := m.proposalID
|
||||
m.done = true
|
||||
m.mu.Unlock()
|
||||
m.recordSync(memory.Event{
|
||||
Kind: kindProposalDismissed,
|
||||
At: m.now(),
|
||||
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
|
||||
})
|
||||
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
|
||||
id := newProposalID()
|
||||
m.proposalID = id
|
||||
m.recordAsync(memory.Event{
|
||||
Kind: kindProposalMade,
|
||||
At: m.now(),
|
||||
Data: map[string]any{
|
||||
"proposal_id": id,
|
||||
"mode": "offscreen",
|
||||
"next_action": p.NextAction,
|
||||
"rationale": p.Rationale,
|
||||
},
|
||||
})
|
||||
})
|
||||
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()
|
||||
id := m.proposalID
|
||||
m.status = statusDone
|
||||
m.done = true
|
||||
m.mu.Unlock()
|
||||
m.recordSync(memory.Event{
|
||||
Kind: kindActionTaken,
|
||||
At: m.now(),
|
||||
Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction},
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
// recordAsync persists an event off the mode's lock, best-effort. Used on the
|
||||
// propose path, which runs under the mode mutex (async apply).
|
||||
func (m *Mode) recordAsync(ev memory.Event) {
|
||||
if m.mem == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := m.mem.Record(context.Background(), ev); err != nil {
|
||||
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// recordSync persists an event inline, best-effort. Used on confirm/dismiss,
|
||||
// which already do synchronous work.
|
||||
func (m *Mode) recordSync(ev memory.Event) {
|
||||
if m.mem == nil {
|
||||
return
|
||||
}
|
||||
if err := m.mem.Record(context.Background(), ev); err != nil {
|
||||
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newProposalID returns a short random hex id correlating a proposal with its
|
||||
// outcome event.
|
||||
func newProposalID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "proposal"
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/memory"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestModeMem is newTestMode with an injected in-memory Store.
|
||||
func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode {
|
||||
t.Helper()
|
||||
svc := harness.Services{
|
||||
AI: ai.NewService(backend),
|
||||
Tasks: ft,
|
||||
Memory: mem,
|
||||
Clock: func() time.Time { return time.Unix(1000, 0) },
|
||||
Notify: func() {},
|
||||
}
|
||||
return New(svc)
|
||||
}
|
||||
|
||||
// waitEvent polls the fake until an event of kind appears, or fails after 2s.
|
||||
func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, e := range f.Events() {
|
||||
if e.Kind == kind {
|
||||
return e
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events())
|
||||
return memory.Event{}
|
||||
}
|
||||
|
||||
func TestProposeRecordsProposalMade(t *testing.T) {
|
||||
mem := memory.NewFake()
|
||||
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
waitStatus(t, m, statusProposed)
|
||||
|
||||
ev := waitEvent(t, mem, "proposal_made")
|
||||
if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" {
|
||||
t.Fatalf("proposal_made data = %+v", ev.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmRecordsActionTakenWithSameID(t *testing.T) {
|
||||
mem := memory.NewFake()
|
||||
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||
_ = m.Command(context.Background(), "start", nil)
|
||||
waitStatus(t, m, statusProposed)
|
||||
made := waitEvent(t, mem, "proposal_made")
|
||||
|
||||
if err := m.Command(context.Background(), "confirm", nil); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
taken := waitEvent(t, mem, "action_taken")
|
||||
if taken.Data["proposal_id"] != made.Data["proposal_id"] {
|
||||
t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissRecordsProposalDismissed(t *testing.T) {
|
||||
mem := memory.NewFake()
|
||||
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||
_ = m.Command(context.Background(), "start", nil)
|
||||
waitStatus(t, m, statusProposed)
|
||||
_ = waitEvent(t, mem, "proposal_made")
|
||||
|
||||
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
|
||||
t.Fatalf("dismiss: %v", err)
|
||||
}
|
||||
_ = waitEvent(t, mem, "proposal_dismissed")
|
||||
}
|
||||
|
||||
func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) {
|
||||
mem := memory.NewFake()
|
||||
ctx := context.Background()
|
||||
// at = the test clock (Unix 1000); confirmed walk, dismissed stretch.
|
||||
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
|
||||
Data: map[string]any{"proposal_id": "w", "next_action": "walk"}})
|
||||
_ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0),
|
||||
Data: map[string]any{"proposal_id": "w"}})
|
||||
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
|
||||
Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}})
|
||||
_ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0),
|
||||
Data: map[string]any{"proposal_id": "s"}})
|
||||
|
||||
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||
brief := m.assembleBrief()
|
||||
|
||||
if !strings.Contains(brief, "Recently proposed") {
|
||||
t.Fatalf("brief missing history section:\n%s", brief)
|
||||
}
|
||||
if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") {
|
||||
t.Fatalf("brief missing confirmed walk:\n%s", brief)
|
||||
}
|
||||
if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") {
|
||||
t.Fatalf("brief missing dismissed stretch:\n%s", brief)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) {
|
||||
m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected
|
||||
if strings.Contains(m.assembleBrief(), "Recently proposed") {
|
||||
t.Fatal("history section should be absent without memory")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// internal/notify/notify.go
|
||||
|
||||
// Package notify is Keel's desktop-notification effector: a best-effort
|
||||
// "interrupt the user" primitive behind a Notifier port. On Linux with
|
||||
// notify-send installed it pops a toast; everywhere else it is a silent nop.
|
||||
// Mirrors internal/enforce: a pure OS primitive, all policy lives in callers.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Notifier shows a desktop notification. Best-effort: it returns an error for
|
||||
// diagnostics, but callers never block on it and treat failure as "no toast."
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, title, body string) error
|
||||
}
|
||||
|
||||
// NewNotifier returns the notify-send adapter when that binary is on PATH, else
|
||||
// a nop. This makes a missing binary (or a non-Linux host) degrade to silence
|
||||
// rather than an error.
|
||||
func NewNotifier() Notifier {
|
||||
if path, err := exec.LookPath("notify-send"); err == nil {
|
||||
return sendNotifier{bin: path}
|
||||
}
|
||||
return nopNotifier{}
|
||||
}
|
||||
|
||||
// sendNotifier shells out to notify-send. The app name groups Keel's toasts.
|
||||
type sendNotifier struct{ bin string }
|
||||
|
||||
func (s sendNotifier) Notify(ctx context.Context, title, body string) error {
|
||||
cmd := exec.CommandContext(ctx, s.bin, "--app-name=Keel", title, body)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("notify: notify-send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nopNotifier is the silent fallback.
|
||||
type nopNotifier struct{}
|
||||
|
||||
func (nopNotifier) Notify(context.Context, string, string) error { return nil }
|
||||
@@ -0,0 +1,22 @@
|
||||
// internal/notify/notify_test.go
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nopNotifier must be returned when notify-send is absent, and must never error.
|
||||
func TestNopNotifierIsSilentAndSafe(t *testing.T) {
|
||||
n := nopNotifier{}
|
||||
if err := n.Notify(context.Background(), "title", "body"); err != nil {
|
||||
t.Fatalf("nop Notify must return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NewNotifier always returns a usable Notifier (never nil), whatever the host.
|
||||
func TestNewNotifierNeverNil(t *testing.T) {
|
||||
if NewNotifier() == nil {
|
||||
t.Fatal("NewNotifier returned nil")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
||||
@@ -16,21 +17,46 @@ import (
|
||||
// without importing the ai package.
|
||||
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
||||
|
||||
// ErrInvalidAmbientMode marks a settings value whose ambient_mode is not a
|
||||
// known mode. The applier returns it (wrapped) so the HTTP layer can map it to
|
||||
// 400 without importing the ambient package.
|
||||
var ErrInvalidAmbientMode = errors.New("settings: invalid ambient mode")
|
||||
|
||||
// Ambient mode dial values.
|
||||
const (
|
||||
AmbientOff = "off"
|
||||
AmbientStatus = "status"
|
||||
AmbientNotify = "notify"
|
||||
)
|
||||
|
||||
// ValidAmbientMode reports whether m is a known ambient mode constant.
|
||||
func ValidAmbientMode(m string) bool {
|
||||
switch m {
|
||||
case AmbientOff, AmbientStatus, AmbientNotify:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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, KEEL_AW_URL,
|
||||
// KEEL_AMBIENT_MODE, KEEL_AMBIENT_CADENCE_SECS.
|
||||
type Settings struct {
|
||||
AIBackend string `json:"ai_backend"`
|
||||
MarvinCmd string `json:"marvin_cmd"`
|
||||
KnowledgePath string `json:"knowledge_path"`
|
||||
AWURL string `json:"aw_url"`
|
||||
AmbientMode string `json:"ambient_mode"`
|
||||
AmbientCadenceSecs int `json:"ambient_cadence_secs"`
|
||||
}
|
||||
|
||||
// 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 +90,34 @@ 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"
|
||||
}
|
||||
awURL := os.Getenv("KEEL_AW_URL")
|
||||
if awURL == "" {
|
||||
awURL = "http://localhost:5600"
|
||||
}
|
||||
ambientMode := os.Getenv("KEEL_AMBIENT_MODE")
|
||||
if ambientMode == "" {
|
||||
ambientMode = AmbientNotify
|
||||
}
|
||||
ambientCadenceSecs := 300
|
||||
if v := os.Getenv("KEEL_AMBIENT_CADENCE_SECS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
ambientCadenceSecs = n
|
||||
}
|
||||
}
|
||||
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"),
|
||||
AWURL: awURL,
|
||||
AmbientMode: ambientMode,
|
||||
AmbientCadenceSecs: ambientCadenceSecs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600"}
|
||||
if err := Save(path, want); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
@@ -29,20 +29,23 @@ 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")
|
||||
t.Setenv("KEEL_AW_URL", "http://aw.local:5600")
|
||||
t.Setenv("KEEL_AMBIENT_MODE", "")
|
||||
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
|
||||
got := SeedFromEnv()
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600", AmbientMode: AmbientNotify, AmbientCadenceSecs: 300}
|
||||
if got != want {
|
||||
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -58,3 +61,52 @@ func TestSaveCreatesDir(t *testing.T) {
|
||||
t.Fatalf("file not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvAWURL(t *testing.T) {
|
||||
t.Setenv("KEEL_AW_URL", "http://aw.example:9999")
|
||||
if got := SeedFromEnv().AWURL; got != "http://aw.example:9999" {
|
||||
t.Fatalf("AWURL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvAWURLDefault(t *testing.T) {
|
||||
t.Setenv("KEEL_AW_URL", "")
|
||||
if got := SeedFromEnv().AWURL; got != "http://localhost:5600" {
|
||||
t.Fatalf("default AWURL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidAmbientMode(t *testing.T) {
|
||||
for _, m := range []string{AmbientOff, AmbientStatus, AmbientNotify} {
|
||||
if !ValidAmbientMode(m) {
|
||||
t.Fatalf("%q should be valid", m)
|
||||
}
|
||||
}
|
||||
if ValidAmbientMode("loud") {
|
||||
t.Fatal("loud should be invalid")
|
||||
}
|
||||
if ValidAmbientMode("") {
|
||||
t.Fatal("empty should be invalid (callers default before validating)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvAmbientDefaults(t *testing.T) {
|
||||
t.Setenv("KEEL_AMBIENT_MODE", "")
|
||||
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
|
||||
s := SeedFromEnv()
|
||||
if s.AmbientMode != AmbientNotify {
|
||||
t.Fatalf("ambient_mode default = %q, want %q", s.AmbientMode, AmbientNotify)
|
||||
}
|
||||
if s.AmbientCadenceSecs != 300 {
|
||||
t.Fatalf("ambient_cadence_secs default = %d, want 300", s.AmbientCadenceSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvAmbientFromEnv(t *testing.T) {
|
||||
t.Setenv("KEEL_AMBIENT_MODE", "status")
|
||||
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "120")
|
||||
s := SeedFromEnv()
|
||||
if s.AmbientMode != "status" || s.AmbientCadenceSecs != 120 {
|
||||
t.Fatalf("env not honored: mode=%q cadence=%d", s.AmbientMode, s.AmbientCadenceSecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,62 @@ 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 the ambient coaching line as
|
||||
// "⚠ <line>" when one is present, else "idle"; an unknown kind renders a generic
|
||||
// fallback so a future mode degrades gracefully.
|
||||
func Render(env mode.Envelope, ambientLine string, now time.Time) string {
|
||||
switch env.ActiveMode {
|
||||
case "":
|
||||
if line := strings.TrimSpace(ambientLine); line != "" {
|
||||
return "⚠ " + line
|
||||
}
|
||||
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 +112,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 +124,12 @@ 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,
|
||||
// rewriting only when the line changes.
|
||||
// Writer periodically renders the harness envelope (plus the ambient line) 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
|
||||
ambient func() string
|
||||
now func() time.Time
|
||||
wake chan struct{}
|
||||
|
||||
@@ -82,13 +137,15 @@ 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 {
|
||||
return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)}
|
||||
// NewWriter builds a Writer for path, reading the harness envelope via state and
|
||||
// the ambient coaching line via ambient (either may be supplied; a nil ambient
|
||||
// is treated as "" by write).
|
||||
func NewWriter(path string, state func() mode.Envelope, ambient func() string) *Writer {
|
||||
return &Writer{path: path, state: state, ambient: ambient, 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,
|
||||
@@ -122,14 +179,18 @@ func (w *Writer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (w *Writer) write() {
|
||||
line := Render(w.state(), w.now())
|
||||
if w.wrote && line == w.last {
|
||||
line := ""
|
||||
if w.ambient != nil {
|
||||
line = w.ambient()
|
||||
}
|
||||
rendered := Render(w.state(), line, w.now())
|
||||
if w.wrote && rendered == w.last {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(w.path, []byte(line), 0o644); err != nil {
|
||||
if err := os.WriteFile(w.path, []byte(rendered), 0o644); err != nil {
|
||||
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||
return
|
||||
}
|
||||
w.last = line
|
||||
w.last = rendered
|
||||
w.wrote = true
|
||||
}
|
||||
|
||||
@@ -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 }, nil)
|
||||
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,9 +105,9 @@ 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))
|
||||
}, nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { w.Run(ctx); close(done) }()
|
||||
@@ -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) }, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -129,6 +148,27 @@ func TestWriterWakeRendersPromptly(t *testing.T) {
|
||||
waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") })
|
||||
}
|
||||
|
||||
func TestRenderIdleShowsAmbientLine(t *testing.T) {
|
||||
got := Render(mode.Envelope{ActiveMode: ""}, "deep in YouTube, no goal", time.Unix(0, 0))
|
||||
if got != "⚠ deep in YouTube, no goal" {
|
||||
t.Fatalf("idle+ambient render = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIdleNoAmbientLineIsIdle(t *testing.T) {
|
||||
if got := Render(mode.Envelope{ActiveMode: ""}, "", time.Unix(0, 0)); got != "idle" {
|
||||
t.Fatalf("idle+no-ambient render = %q, want idle", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderActiveModeIgnoresAmbientLine(t *testing.T) {
|
||||
// A non-idle envelope renders its own mode, never the ambient line.
|
||||
got := Render(mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "walk"}}, "ambient ignored", time.Unix(0, 0))
|
||||
if got == "ambient ignored" || got == "⚠ ambient ignored" {
|
||||
t.Fatalf("active mode must not render the ambient line, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func fileEquals(path, want string) bool {
|
||||
b, err := os.ReadFile(path)
|
||||
return err == nil && string(b) == want
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -45,6 +45,19 @@ func (s *Server) handlePostSettings(c *gin.Context) {
|
||||
s.settingsMu.Lock()
|
||||
apply := s.applyFn
|
||||
path := s.settingsPath
|
||||
// aw_url is not exposed in the settings form; preserve the persisted value
|
||||
// so a UI save never blanks a configured AW URL.
|
||||
if req.AWURL == "" {
|
||||
req.AWURL = s.settings.AWURL
|
||||
}
|
||||
// Likewise preserve ambient fields when a caller omits them, so a partial
|
||||
// POST never clobbers a configured ambient dial.
|
||||
if req.AmbientMode == "" {
|
||||
req.AmbientMode = s.settings.AmbientMode
|
||||
}
|
||||
if req.AmbientCadenceSecs <= 0 {
|
||||
req.AmbientCadenceSecs = s.settings.AmbientCadenceSecs
|
||||
}
|
||||
s.settingsMu.Unlock()
|
||||
if apply == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
||||
@@ -81,7 +94,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"))
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/settings"
|
||||
)
|
||||
|
||||
// fakeApplier records the last settings it was asked to apply and can be told to
|
||||
@@ -29,7 +31,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 +53,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 +80,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 +99,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()
|
||||
@@ -111,6 +113,37 @@ func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsPreservesAWURL(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{}
|
||||
cur := settings.Settings{AIBackend: "claude", AWURL: "http://aw.remote:9999"}
|
||||
s.SetSettings(path, cur, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
// POST a body that omits aw_url (mirrors what the real UI form sends).
|
||||
body := `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":""}`
|
||||
w := post(t, r, "/settings", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
if fa.called != 1 {
|
||||
t.Fatalf("applier called %d times, want 1", fa.called)
|
||||
}
|
||||
// (a) applyFn must have received the preserved AWURL.
|
||||
if fa.last.AWURL != "http://aw.remote:9999" {
|
||||
t.Errorf("applied AWURL = %q, want http://aw.remote:9999", fa.last.AWURL)
|
||||
}
|
||||
// (b) persisted file must also preserve the AWURL.
|
||||
saved, err := settings.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("settings file not loadable: %v", err)
|
||||
}
|
||||
if saved.AWURL != "http://aw.remote:9999" {
|
||||
t.Errorf("saved AWURL = %q, want http://aw.remote:9999", saved.AWURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
@@ -123,7 +156,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 +212,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)
|
||||
@@ -189,3 +222,30 @@ func TestBrowseBadDirIs400(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsPreservesBlankAmbientFields(t *testing.T) {
|
||||
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||
s := NewServer(h)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
current := settings.Settings{AIBackend: "claude", AmbientMode: "status", AmbientCadenceSecs: 120}
|
||||
s.SetSettings(path, current, func(settings.Settings) error { return nil })
|
||||
|
||||
// A POST that omits the ambient fields must not blank them.
|
||||
body := `{"ai_backend":"claude"}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.Router().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
saved, err := settings.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if saved.AmbientMode != "status" || saved.AmbientCadenceSecs != 120 {
|
||||
t.Fatalf("ambient fields not preserved: mode=%q cadence=%d", saved.AmbientMode, saved.AmbientCadenceSecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 @@ 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; } }
|
||||
|
||||
.ambient-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: #3a2a00;
|
||||
color: #ffd479;
|
||||
border: 1px solid #6b4f00;
|
||||
}
|
||||
.ambient-banner .ambient-line { font-weight: 600; }
|
||||
.ambient-banner .ambient-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
+108
-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,99 @@ 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');
|
||||
}
|
||||
|
||||
// updateAmbient shows the always-on drift banner whenever the server reports an
|
||||
// ambient coaching line, regardless of the active mode. It clears when empty.
|
||||
function updateAmbient(env) {
|
||||
const el = document.getElementById('ambientBanner');
|
||||
const line = (env && env.ambient) || '';
|
||||
if (!line) { el.hidden = true; el.innerHTML = ''; return; }
|
||||
el.hidden = false;
|
||||
el.innerHTML = `<span class="ambient-line">⚠ ${esc(line)}</span>
|
||||
<span class="ambient-actions">
|
||||
<button type="button" class="btn btn-ghost" id="ambientSnooze">Snooze 1h</button>
|
||||
<button type="button" class="btn btn-primary" id="ambientFocus">Start focus</button>
|
||||
</span>`;
|
||||
document.getElementById('ambientSnooze').onclick = () => post('/ambient/snooze');
|
||||
document.getElementById('ambientFocus').onclick = () => post('/mode/focus/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) || '';
|
||||
updateAmbient(env);
|
||||
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,10 +433,18 @@ 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>
|
||||
<label>Ambient coach</label>
|
||||
<select id="setAmbient">
|
||||
<option value="notify">notify (status + toast)</option>
|
||||
<option value="status">status only</option>
|
||||
<option value="off">off</option>
|
||||
</select>
|
||||
<label>Ambient check every (seconds)</label>
|
||||
<input id="setAmbientCadence" type="number" min="30" placeholder="300">
|
||||
<div id="setError" class="set-error"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
||||
@@ -362,6 +454,8 @@ function openSettings() {
|
||||
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
||||
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
||||
document.getElementById('setKnow').value = s.knowledge_path || '';
|
||||
document.getElementById('setAmbient').value = s.ambient_mode || 'notify';
|
||||
document.getElementById('setAmbientCadence').value = s.ambient_cadence_secs || 300;
|
||||
ov.hidden = false;
|
||||
document.getElementById('setCancel').onclick = closeSettings;
|
||||
document.getElementById('setSave').onclick = saveSettings;
|
||||
@@ -387,6 +481,8 @@ function saveSettings() {
|
||||
ai_backend: document.getElementById('setBackend').value,
|
||||
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
||||
knowledge_path: document.getElementById('setKnow').value.trim(),
|
||||
ambient_mode: document.getElementById('setAmbient').value,
|
||||
ambient_cadence_secs: parseInt(document.getElementById('setAmbientCadence').value, 10) || 300,
|
||||
};
|
||||
post('/settings', body).then(r => {
|
||||
if (r.ok) { closeSettings(); return; }
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
<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 id="ambientBanner" class="ambient-banner" hidden></div>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||
</main>
|
||||
|
||||
+97
-87
@@ -7,15 +7,16 @@ 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"
|
||||
"keel/internal/mode/focus/statemachine"
|
||||
"keel/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -23,11 +24,14 @@ 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
|
||||
|
||||
ambientLine func() string
|
||||
ambientSnooze func(time.Duration)
|
||||
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
|
||||
@@ -38,16 +42,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,102 +76,103 @@ 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)
|
||||
r.POST("/ambient/snooze", s.handleAmbientSnooze)
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) stateJSON() string {
|
||||
data, _ := json.Marshal(s.ctrl.State())
|
||||
line := ""
|
||||
if s.ambientLine != nil {
|
||||
line = s.ambientLine()
|
||||
}
|
||||
payload := struct {
|
||||
mode.Envelope
|
||||
Ambient string `json:"ambient"`
|
||||
}{Envelope: s.h.State(), Ambient: line}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// SetAmbient injects the ambient sentinel's line accessor and snooze action,
|
||||
// wired by main. Called once at startup before serving.
|
||||
func (s *Server) SetAmbient(line func() string, snooze func(time.Duration)) {
|
||||
s.ambientLine = line
|
||||
s.ambientSnooze = snooze
|
||||
}
|
||||
|
||||
// Broadcast pushes the current state envelope to SSE subscribers. Exposed so the
|
||||
// ambient sentinel's change signal can refresh the UI alongside harness changes.
|
||||
func (s *Server) Broadcast() { s.broadcast() }
|
||||
|
||||
// handleAmbientSnooze mutes the ambient coach for an hour.
|
||||
func (s *Server) handleAmbientSnooze(c *gin.Context) {
|
||||
if s.ambientSnooze != nil {
|
||||
s.ambientSnooze(time.Hour)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
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()})
|
||||
}
|
||||
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) {
|
||||
// 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, 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())
|
||||
s.respond(c, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(c *gin.Context) {
|
||||
@@ -196,9 +205,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 +224,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 +237,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()
|
||||
}
|
||||
|
||||
+218
-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()
|
||||
@@ -365,6 +417,34 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateJSONIncludesAmbient(t *testing.T) {
|
||||
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||
s := NewServer(h)
|
||||
s.SetAmbient(func() string { return "deep in YouTube" }, func(time.Duration) {})
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"ambient":"deep in YouTube"`) {
|
||||
t.Fatalf("state JSON missing ambient field: %s", js)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbientSnoozeRoute(t *testing.T) {
|
||||
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||
s := NewServer(h)
|
||||
var snoozed time.Duration
|
||||
s.SetAmbient(func() string { return "" }, func(d time.Duration) { snoozed = d })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/ambient/snooze", nil)
|
||||
s.Router().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("snooze status = %d, want 204", w.Code)
|
||||
}
|
||||
if snoozed != time.Hour {
|
||||
t.Fatalf("snooze duration = %v, want 1h", snoozed)
|
||||
}
|
||||
}
|
||||
|
||||
type stubJudge struct{ verdict ai.Verdict }
|
||||
|
||||
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||
@@ -372,19 +452,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 +477,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 +493,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