Compare commits

..

32 Commits

Author SHA1 Message Date
felixm 6cb6adf7ef docs(settings): list the ambient env vars in the Settings comment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:02:33 -04:00
felixm b36964a427 fix(keeld): log the effective ambient cadence, not the raw setting
A settings.json predating the ambient fields deserializes cadence to 0;
the sentinel clamps that to its 5m default, so log the clamped value
instead of a misleading cadence=0s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:59:42 -04:00
felixm 6c81a0d4f8 feat(keeld): wire the ambient drift coach sentinel
Automated smoke test results:
- GET /: 200
- POST /ambient/snooze: 204
- GET /settings: includes ambient_mode and ambient_cadence_secs fields
- daemon log: "ambient: mode=notify cadence=0s" (cadence=0 because existing
  settings.json predates these fields; ambient.New defaults to 5 min when
  cadence is non-positive — correct degradation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:57:14 -04:00
felixm aaed1ad265 feat(web): ambient banner, snooze/focus actions, settings controls 2026-06-05 21:51:49 -04:00
felixm fa178a1fd2 feat(web): ambient line in state envelope + /ambient/snooze
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:46:45 -04:00
felixm ef845ce463 feat(settings): ambient_mode and ambient_cadence_secs fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:43:45 -04:00
felixm 4b2cb4242f feat(statusfile): render the ambient drift line when idle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:41:24 -04:00
felixm 7507cc6d28 fix(ambient): re-check active mode after the judge call (coexistence TOCTOU) 2026-06-05 21:39:46 -04:00
felixm c963895599 feat(ambient): evaluate loop, two-stage surfacing, snooze, config 2026-06-05 21:35:56 -04:00
felixm af3a0e2ac8 feat(ambient): sentinel core — evidence ring, Line, deps 2026-06-05 21:33:59 -04:00
felixm b37ee40dd2 feat(ai): AmbientDrift — frame-grounded drift judge 2026-06-05 21:30:37 -04:00
felixm 2690fdd513 refactor(offscreen): assemble frame via shared internal/frame 2026-06-05 21:28:21 -04:00
felixm 703218658b feat(frame): shared ~/owc frame assembler extracted for reuse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:26:21 -04:00
felixm 607ad74def feat(notify): notify-send effector with nop fallback 2026-06-05 21:22:12 -04:00
felixm aac3173874 docs: implementation plan for the ambient drift coach
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:20:11 -04:00
felixm 9d7786c3e6 docs: design the ambient drift coach (always-on sentinel)
The first Control loop / scheduler component (architecture §3): an
always-on sentinel beside the one-mode harness that judges activity
against the ~/owc frame when no session is declared, and surfaces drift
to the status bar, a notify-send toast, and a web-UI banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:03:10 -04:00
felixm 682eb603fa fix(evidence): X11 sensor polls so tab/title changes are tracked
The X11 source only re-read the active window on _NET_ACTIVE_WINDOW
changes, so switching a browser tab — which changes the window title
but not the active window — was invisible. All the time on the new tab
was credited to the stale title (e.g. reading a Consume article showed
up as time on the Keel tab).

Mirror the Windows sensor: poll the active window every 750ms and emit
only when the window or its title changes. A shared, display-free
pollLoop carries the change-dedup and is unit-tested for the exact
regression (a title-only change must emit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:32:24 -04:00
felixm 6d296bf743 docs: mark off-screen AW memory as shipped in architecture §5 2026-06-05 20:08:20 -04:00
felixm c855731c39 fix(web): preserve aw_url when a settings POST omits it
A UI save was silently blanking the configured AW URL because
handlePostSettings bound the whole Settings from the request and saved
it as-is. Now reads s.settings.AWURL under the existing settingsMu lock
and carries it forward when req.AWURL is empty, so fields not exposed in
the form are never erased.
2026-06-05 20:05:59 -04:00
felixm bd2b8a41fd feat(offscreen): read recent proposals into the brief; prompt follows up
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:56:59 -04:00
felixm 16b44c9e90 feat(offscreen): record proposal_made/action_taken/proposal_dismissed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:51:18 -04:00
felixm 5bdb98c1a7 feat(keeld): construct AW memory store, nop fallback when AW is down 2026-06-05 19:45:24 -04:00
felixm 67f91442a4 feat(settings): KEEL_AW_URL with localhost default
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:39:49 -04:00
felixm 3a4f498f86 feat(memory): AW-backed Store with over-fetch kind filtering 2026-06-05 19:38:35 -04:00
felixm 7d4f9ca4a8 feat(memory): Store port with nop and fake adapters 2026-06-05 19:33:46 -04:00
felixm 582af0d68b feat(aw): Insert and Recent over the events endpoint 2026-06-05 19:32:43 -04:00
felixm b422b6deb6 feat(aw): AW REST client with idempotent EnsureBucket 2026-06-05 19:29:41 -04:00
felixm 3386405c7c docs: implementation plan for off-screen AW-backed memory
Nine TDD tasks: internal/aw REST client (EnsureBucket/Insert/Recent),
internal/memory port (Store + awStore over-fetch filter + nop + fake),
KEEL_AW_URL setting, harness.Services.Memory wiring with nop fallback,
off-screen write path (proposal_made/action_taken/proposal_dismissed) and
read path (## Recently proposed + follow-up prompt rule), plus full and
live verification. Derived from the 2026-06-05 design spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:16:47 -04:00
felixm 730ffe33ce docs: design off-screen AW-backed memory (first durable-memory slice)
Brainstormed design for Keel's first cross-run memory: a thin memory.Store
port backed by an internal/aw REST client writing to the keel.events bucket,
with off-screen as the single wired consumer (records proposal_made /
action_taken / proposal_dismissed and reads its recent history back into the
brief for fresher, follow-up-aware proposals). Brain-only, best-effort,
degrades to nop when AW is down. Realizes keel-architecture.md §5/§7 at the
smallest honest vertical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:08:41 -04:00
felixm 7d69a1f320 Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:00:49 -04:00
felixm 258de2c14b docs: make Keel the repo front door + add agent context
- README: lead with Keel (the human-harness), frame AntiDrift as the focus
  mode, keep the milestone history. Fix the "rename deferred" wording (the
  dir/repo is already keel; only the code identity stays antidrift) and drop
  the dangling 2026-05-31 spec reference (that spec was intentionally removed).
- keel-architecture.md: update the rename status in the header and §8 — the
  directory is keel; the Go module / antidriftd binary / ANTIDRIFT_* env /
  ~/.antidrift -> ~/.keel migration land with the controller refactor.
- add AGENTS.md (agent orientation) + CLAUDE.md symlink so claude / codex /
  Hermes auto-load Keel context when launched in this repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:20:51 -04:00
felixm f9e580ff7e docs: name the cockpit Keel and add its architecture
Move the higher-goals cockpit architecture into this repo: AntiDrift is
Keel's seed, with its focus harness becoming one mode of a general
collect -> brain -> act loop. Records the locked decisions: the product
is the harness around a swappable brain (claude/codex/Hermes); storage is
ActivityWatch; two surfaces (web UI + status bar); gated -> autonomous
effectors. Code/repo rename to keel deferred until the controller-refactor
decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:57:14 -04:00
77 changed files with 10457 additions and 633 deletions
+1 -1
View File
@@ -2,5 +2,5 @@
.superpowers/
# Go
/antidriftd
/keeld
*.test
+64
View File
@@ -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.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+39 -12
View File
@@ -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).
-142
View File
@@ -1,142 +0,0 @@
// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI
// and owns the commitment state machine.
package main
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"time"
"antidrift/internal/ai"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/statusfile"
"antidrift/internal/store"
"antidrift/internal/tasks"
"antidrift/internal/web"
)
const addr = "localhost:7777"
func main() {
path, err := store.DefaultPath()
if err != nil {
log.Fatalf("resolve snapshot path: %v", err)
}
ctrl, err := session.New(path)
if err != nil {
log.Fatalf("load session: %v", err)
}
srv := web.NewServer(ctrl)
srv.Init() // re-arm or expire a restored Active session
// Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// The knowledge source is a single file adapter whose path is driven entirely
// by SetKnowledgePath, so startup and live settings edits share one code path.
ctrl.SetKnowledge(knowledge.NewFileSource(""))
// applyFn re-wires the running daemon from a Settings value: AI backend +
// service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It
// validates the backend FIRST and mutates nothing on failure, so POST /settings
// can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
ctrl.SetCoach(svc)
ctrl.SetDriftJudge(svc)
ctrl.SetNudge(svc)
ctrl.SetReviewer(svc)
ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))
ctrl.SetKnowledgePath(s.KnowledgePath)
return nil
}
// Apply once at startup. A bad persisted backend should not be fatal: fall back
// to claude (always valid), persist the correction, and re-apply.
if err := applyFn(cfg); err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
if err := applyFn(cfg); err != nil {
log.Fatalf("settings: apply failed even with claude: %v", err)
}
}
srv.SetSettings(settingsPath, cfg, applyFn)
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, ctrl.State)
// Re-render the bar on every state change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
ctrl.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
ctrl.SetGuard(enforce.NewGuard())
log.Printf("enforce: window-minimize guard")
src := evidence.NewSource()
go src.Watch(context.Background(), ctrl.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("antidriftd listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+250
View File
@@ -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)
}
}
+305
View File
@@ -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.
+1 -1
View File
@@ -1,4 +1,4 @@
module antidrift
module keel
go 1.26.3
+79
View File
@@ -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
}
+68
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string {
}
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
f, err := os.CreateTemp("", "antidrift-codex-*.out")
f, err := os.CreateTemp("", "keel-codex-*.out")
if err != nil {
return "", fmt.Errorf("codex: temp file: %w", err)
}
+73
View File
@@ -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
}
+50
View File
@@ -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)
}
}
+323
View File
@@ -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()
}
+282
View File
@@ -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)
}
}
+124
View File
@@ -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
}
+108
View File
@@ -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)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package enforce
import (
"context"
"antidrift/internal/winapi"
"keel/internal/winapi"
)
// NewGuard returns the Windows window-minimize guard.
+1 -1
View File
@@ -3,7 +3,7 @@ package evidence
import (
"strings"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
+1 -1
View File
@@ -3,7 +3,7 @@ package evidence
import (
"testing"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
+3 -2
View File
@@ -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))
}
+44
View File
@@ -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()
}
}
}
+95
View File
@@ -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)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"context"
"time"
"antidrift/internal/winapi"
"keel/internal/winapi"
)
// pollInterval is how often the Windows sensor samples the foreground window.
+15 -38
View File
@@ -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},
}
}
+139
View File
@@ -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)"
}
+70
View File
@@ -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:])
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package harness is the generic host: it owns shared services and exactly one
// active mode, and routes evidence, expiry, and commands to it.
package harness
import (
"context"
"sync"
"time"
)
// Async runs generation-guarded background work for a mode. It preserves the
// lock discipline of the original session.runFetchAsync: fetch runs with no
// lock held; stale and apply run under the mode's mutex; notify fires once,
// after the lock is released.
type Async struct {
mu *sync.Mutex
notify func()
}
// NewAsync binds the helper to a mode's mutex and the harness change-notify.
func NewAsync(mu *sync.Mutex, notify func()) Async {
return Async{mu: mu, notify: notify}
}
// Run launches a background fetch. stale returns true to DISCARD the result
// (a newer generation superseded it). apply records a non-stale result under
// the mutex and must NOT call notify — Run owns the post-unlock notify.
func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fetch(ctx)
a.mu.Lock()
if stale() {
a.mu.Unlock()
return
}
apply()
a.mu.Unlock()
if a.notify != nil {
a.notify()
}
}()
}
+48
View File
@@ -0,0 +1,48 @@
package harness
import (
"context"
"sync"
"testing"
"time"
)
func TestAsyncRunAppliesAndNotifies(t *testing.T) {
var mu sync.Mutex
notified := make(chan struct{}, 1)
a := NewAsync(&mu, func() { notified <- struct{}{} })
var applied bool
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { return false },
func() { applied = true },
)
select {
case <-notified:
case <-time.After(time.Second):
t.Fatal("notify never fired")
}
mu.Lock()
defer mu.Unlock()
if !applied {
t.Fatal("apply did not run")
}
}
func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) {
var mu sync.Mutex
a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") })
done := make(chan struct{})
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { close(done); return true },
func() { t.Fatal("apply ran on stale result") },
)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("stale guard never evaluated")
}
time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface
}
+187
View File
@@ -0,0 +1,187 @@
package harness
import (
"context"
"encoding/json"
"errors"
"sync"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
var (
ErrBusy = errors.New("harness: another mode is active")
ErrUnknownMode = errors.New("harness: unknown mode")
ErrIdle = errors.New("harness: no active mode")
)
// Factory builds a mode from the shared services, in its initial active state.
type Factory func(Services) mode.Mode
type Harness struct {
mu sync.Mutex
active mode.Mode
factories map[string]Factory
services Services
onChange []func()
}
// New wires Services.Notify to the harness so every mode's async completion
// reaches all listeners, then stores the services for the factories to receive.
func New(services Services, factories map[string]Factory) *Harness {
h := &Harness{factories: factories}
services.Notify = h.notify
h.services = services
return h
}
// AddOnChange registers a change listener (web broadcaster, status writer).
func (h *Harness) AddOnChange(f func()) {
h.mu.Lock()
h.onChange = append(h.onChange, f)
h.mu.Unlock()
}
// SetServices swaps the services future Start calls hand to factories. In v1 it
// affects the NEXT mode start only; an already-active mode keeps the services it
// was built with (settings changes take effect on the next session).
func (h *Harness) SetServices(s Services) {
s.Notify = h.notify
h.mu.Lock()
h.services = s
h.mu.Unlock()
}
// Services returns the shared services the harness hands to factories, with
// Notify wired to the harness change fan-out. Use it to build a mode for Adopt
// (e.g. startup restoration) so the restored mode's async work reaches the
// harness's onChange listeners.
func (h *Harness) Services() Services {
h.mu.Lock()
defer h.mu.Unlock()
return h.services
}
func (h *Harness) notify() {
h.mu.Lock()
fs := append([]func(){}, h.onChange...)
h.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// Adopt installs an already-constructed mode as active (startup restoration).
// It fails if a mode is already active.
func (h *Harness) Adopt(m mode.Mode) error {
h.mu.Lock()
if h.active != nil {
h.mu.Unlock()
return ErrBusy
}
h.active = m
h.mu.Unlock()
h.notify()
return nil
}
// Start activates a mode by kind. Idempotent if that kind is already active;
// ErrBusy if a different kind is active.
func (h *Harness) Start(kind string) error {
h.mu.Lock()
if h.active != nil {
busy := h.active.Kind() != kind
h.mu.Unlock()
if busy {
return ErrBusy
}
return nil
}
f, ok := h.factories[kind]
if !ok {
h.mu.Unlock()
return ErrUnknownMode
}
h.active = f(h.services)
h.mu.Unlock()
h.notify()
return nil
}
// Command routes to the active mode, then releases it if it went inactive.
func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return ErrIdle
}
if err := m.Command(ctx, name, payload); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
func (h *Harness) releaseIfDone() {
h.mu.Lock()
if h.active != nil && !h.active.Active() {
h.active = nil
}
h.mu.Unlock()
}
// State returns the surfacing envelope.
func (h *Harness) State() mode.Envelope {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return mode.Envelope{}
}
return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()}
}
// RecordWindow forwards a window snapshot iff the active mode consumes evidence.
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if c, ok := m.(mode.EvidenceConsumer); ok {
c.OnWindow(w)
}
}
// Deadline returns the active mode's deadline iff it is an Expirer.
func (h *Harness) Deadline() time.Time {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if e, ok := m.(mode.Expirer); ok {
return e.Deadline()
}
return time.Time{}
}
// Expire fires the active mode's expiry iff it is an Expirer, then releases it
// if it went inactive.
func (h *Harness) Expire() error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
e, ok := m.(mode.Expirer)
if !ok {
return ErrIdle
}
if err := e.Expire(); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package harness
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
// fakeMode implements Mode and (optionally) the capability interfaces.
type fakeMode struct {
mu sync.Mutex
kind string
active bool
cmds []string
windows int
deadline time.Time
expired bool
evidence bool // implements EvidenceConsumer when true
expirer bool // implements Expirer when true
}
func (f *fakeMode) Kind() string { return f.kind }
func (f *fakeMode) View() any { return map[string]bool{"active": f.active} }
func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active }
func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error {
f.mu.Lock()
defer f.mu.Unlock()
f.cmds = append(f.cmds, name)
if name == "finish" {
f.active = false
}
return nil
}
type evidenceMode struct{ *fakeMode }
func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() }
type expirerMode struct{ *fakeMode }
func (e expirerMode) Deadline() time.Time { return e.deadline }
func (e expirerMode) Expire() error {
e.mu.Lock()
e.expired = true
e.active = false
e.mu.Unlock()
return nil
}
func newHarness(m mode.Mode) *Harness {
return New(Services{Clock: time.Now}, map[string]Factory{
m.Kind(): func(Services) mode.Mode { return m },
})
}
func TestStartSingleActiveInvariant(t *testing.T) {
h := New(Services{}, map[string]Factory{
"a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} },
"b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} },
})
if err := h.Start("a"); err != nil {
t.Fatalf("Start a: %v", err)
}
if err := h.Start("a"); err != nil {
t.Fatalf("re-Start a should be idempotent: %v", err)
}
if err := h.Start("b"); err != ErrBusy {
t.Fatalf("Start b while a active = %v, want ErrBusy", err)
}
if got := h.State().ActiveMode; got != "a" {
t.Fatalf("ActiveMode = %q, want a", got)
}
}
func TestCommandReleasesWhenInactive(t *testing.T) {
m := &fakeMode{kind: "a", active: true}
h := newHarness(m)
_ = h.Start("a")
if err := h.Command(context.Background(), "finish", nil); err != nil {
t.Fatalf("Command: %v", err)
}
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after finish ActiveMode = %q, want idle", got)
}
}
func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic
if plain.windows != 0 {
t.Fatalf("plain mode received %d windows, want 0", plain.windows)
}
ev := evidenceMode{&fakeMode{kind: "ev", active: true}}
h2 := newHarness(ev)
_ = h2.Start("ev")
h2.RecordWindow(evidence.WindowSnapshot{})
if ev.windows != 1 {
t.Fatalf("evidence mode received %d windows, want 1", ev.windows)
}
}
func TestExpiryOnlyForExpirers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
if err := h.Expire(); err != ErrIdle {
t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err)
}
if !h.Deadline().IsZero() {
t.Fatal("non-expirer Deadline should be zero")
}
ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}}
h2 := newHarness(ex)
_ = h2.Start("ex")
if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) {
t.Fatalf("Deadline = %v, want unix 100", got)
}
if err := h2.Expire(); err != nil {
t.Fatalf("Expire: %v", err)
}
if got := h2.State().ActiveMode; got != "" {
t.Fatalf("after Expire ActiveMode = %q, want idle", got)
}
}
func TestCommandWhileIdle(t *testing.T) {
h := New(Services{}, map[string]Factory{})
if err := h.Command(context.Background(), "x", nil); err != ErrIdle {
t.Fatalf("Command while idle = %v, want ErrIdle", err)
}
}
func TestServicesNotifyReachesOnChangeListeners(t *testing.T) {
h := New(Services{}, map[string]Factory{})
fired := make(chan struct{}, 1)
h.AddOnChange(func() { fired <- struct{}{} })
svc := h.Services()
if svc.Notify == nil {
t.Fatal("Services().Notify is nil; restore path would not propagate updates")
}
svc.Notify() // simulate a restored mode's async completion firing notify
select {
case <-fired:
case <-time.After(time.Second):
t.Fatal("Services().Notify did not reach the registered onChange listener")
}
}
+27
View File
@@ -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()
}
+3 -3
View File
@@ -22,7 +22,7 @@ type FileSource struct {
}
// NewFileSource builds the adapter. defaultPath is used when Load receives an
// empty path; if it too is empty, Load falls back to ~/.antidrift/knowledge.md.
// empty path; if it too is empty, Load falls back to ~/.keel/knowledge.md.
func NewFileSource(defaultPath string) *FileSource {
return &FileSource{defaultPath: defaultPath}
}
@@ -46,7 +46,7 @@ func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) {
return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil
}
// resolve picks path, else the default, else ~/.antidrift/knowledge.md; expands
// resolve picks path, else the default, else ~/.keel/knowledge.md; expands
// a leading ~; and makes the result absolute for stable display.
func (s *FileSource) resolve(path string) string {
p := path
@@ -55,7 +55,7 @@ func (s *FileSource) resolve(path string) string {
}
if p == "" {
if home, err := os.UserHomeDir(); err == nil {
p = filepath.Join(home, ".antidrift", "knowledge.md")
p = filepath.Join(home, ".keel", "knowledge.md")
}
}
p = expandTilde(p)
+132
View File
@@ -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
}
+115
View File
@@ -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 = ""
+50
View File
@@ -0,0 +1,50 @@
package focus
import (
"path/filepath"
"keel/internal/harness"
"keel/internal/mode"
)
// build constructs a focus Mode under the mode's namespaced dir and injects the
// shared services through the existing setters (the same API tests use).
func build(svc harness.Services) (*Mode, error) {
m, err := New(filepath.Join(svc.Dir, "state.json"))
if err != nil {
return nil, err
}
if svc.Clock != nil {
m.SetClock(svc.Clock)
}
m.SetCoach(svc.AI)
m.SetReviewer(svc.AI)
m.SetDriftJudge(svc.AI)
m.SetNudge(svc.AI)
m.SetTasks(svc.Tasks)
m.SetKnowledge(svc.Knowledge)
m.SetGuard(svc.Enforce)
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m, nil
}
// Factory builds a fresh focus mode entering Planning (used by harness.Start).
func Factory(svc harness.Services) mode.Mode {
m, err := build(svc)
if err != nil {
m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works
m.SetOnChange(svc.Notify)
}
_ = m.EnterPlanning()
return m
}
// Restore loads a persisted focus session and reports whether one was live
// (Planning/Active/Transition/Review). Used at startup to re-adopt a session.
func Restore(svc harness.Services) (*Mode, bool, error) {
m, err := build(svc)
if err != nil {
return nil, false, err
}
return m, m.Active(), nil
}
@@ -1,8 +1,8 @@
// Package session owns the daemon's in-memory state of truth and persists a
// Package focus owns the daemon's in-memory state of truth and persists a
// snapshot on every change. Transitions go through the pure statemachine. It
// also owns per-session evidence stats: it accumulates active-window time while
// Active, logs raw focus events, and seals each session into the audit chain.
package session
package focus
import (
"errors"
@@ -12,14 +12,15 @@ import (
"sync"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/statemachine"
"antidrift/internal/store"
"antidrift/internal/tasks"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/mode/focus/statemachine"
"keel/internal/store"
"keel/internal/tasks"
"github.com/google/uuid"
)
@@ -29,13 +30,14 @@ const (
sessionRetention = 30 * 24 * time.Hour
)
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
var ErrNotPlanning = errors.New("focus: coaching is only available while planning")
var ErrNotActive = errors.New("session: only available while a commitment is active")
var ErrNotActive = errors.New("focus: only available while a commitment is active")
// Controller holds runtime state and the active commitment behind a mutex.
type Controller struct {
// Mode holds runtime state and the active commitment behind a mutex.
type Mode struct {
mu sync.Mutex
async harness.Async
runtimeState domain.RuntimeState
commitment *domain.Commitment
deadline time.Time
@@ -90,13 +92,13 @@ type Controller struct {
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
// in-memory stats from the raw log if a live session was interrupted.
func New(snapshotPath string) (*Controller, error) {
func New(snapshotPath string) (*Mode, error) {
s, err := store.Load(snapshotPath)
if err != nil {
return nil, err
}
dir := filepath.Dir(snapshotPath)
c := &Controller{
c := &Mode{
runtimeState: s.RuntimeState,
commitment: s.Commitment,
snapshotPath: snapshotPath,
@@ -114,6 +116,7 @@ func New(snapshotPath string) (*Controller, error) {
if c.runtimeState == "" {
c.runtimeState = domain.RuntimeLocked
}
c.async = harness.NewAsync(&c.mu, c.notify)
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
c.allowedClasses = s.AllowedWindowClasses
@@ -129,7 +132,7 @@ func New(snapshotPath string) (*Controller, error) {
// SetClock overrides the time source (tests only). Call before starting a
// session.
func (c *Controller) SetClock(f func() time.Time) {
func (c *Mode) SetClock(f func() time.Time) {
c.mu.Lock()
c.clock = f
c.mu.Unlock()
@@ -138,7 +141,7 @@ func (c *Controller) SetClock(f func() time.Time) {
// SetOnChange registers the sole change listener, replacing any already set. A
// listener is fired after a state change (focus updates, role results) with the
// mutex released. Use AddOnChange to register additional listeners.
func (c *Controller) SetOnChange(f func()) {
func (c *Mode) SetOnChange(f func()) {
c.mu.Lock()
c.onChanges = []func(){f}
c.mu.Unlock()
@@ -147,13 +150,13 @@ func (c *Controller) SetOnChange(f func()) {
// AddOnChange registers an additional change listener alongside any already set,
// so several consumers (the web broadcaster, the status-file writer) all react
// to the same notification rather than only the last one wired.
func (c *Controller) AddOnChange(f func()) {
func (c *Mode) AddOnChange(f func()) {
c.mu.Lock()
c.onChanges = append(c.onChanges, f)
c.mu.Unlock()
}
func (c *Controller) notify() {
func (c *Mode) notify() {
c.mu.Lock()
fs := append([]func(){}, c.onChanges...)
c.mu.Unlock()
@@ -165,20 +168,20 @@ func (c *Controller) notify() {
}
// State returns the current broadcastable state. Safe for concurrent use.
func (c *Controller) State() State {
func (c *Mode) State() State {
c.mu.Lock()
defer c.mu.Unlock()
return c.stateLocked()
}
// Deadline returns the active commitment deadline, or the zero time.
func (c *Controller) Deadline() time.Time {
func (c *Mode) Deadline() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.deadline
}
func (c *Controller) persistLocked() error {
func (c *Mode) persistLocked() error {
snap := store.Snapshot{
RuntimeState: c.runtimeState,
Commitment: c.commitment,
@@ -199,7 +202,7 @@ func (c *Controller) persistLocked() error {
}
// EnterPlanning moves Locked -> Planning.
func (c *Controller) EnterPlanning() error {
func (c *Mode) EnterPlanning() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
@@ -214,7 +217,7 @@ func (c *Controller) EnterPlanning() error {
}
// AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string {
func (c *Mode) AllowedClassesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.allowedClasses...)
@@ -222,7 +225,7 @@ func (c *Controller) AllowedClassesForTest() []string {
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
// only.
func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel {
c.mu.Lock()
defer c.mu.Unlock()
return c.enforcementLevel
@@ -231,7 +234,7 @@ func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
// StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning ->
// Active.
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
func (c *Mode) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
c.mu.Lock()
defer c.mu.Unlock()
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
@@ -271,12 +274,12 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
}
// Complete moves Active -> Review with a "completed" outcome.
func (c *Controller) Complete() error { return c.enterReview("completed") }
func (c *Mode) Complete() error { return c.enterReview("completed") }
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
func (c *Controller) Expire() error { return c.enterReview("expired") }
func (c *Mode) Expire() error { return c.enterReview("expired") }
func (c *Controller) enterReview(outcome string) error {
func (c *Mode) enterReview(outcome string) error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
@@ -303,7 +306,7 @@ func (c *Controller) enterReview(outcome string) error {
// End moves Review -> Locked, writes the session summary to the audit chain,
// and clears the commitment and stats.
func (c *Controller) End() error {
func (c *Mode) End() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
@@ -314,7 +317,7 @@ func (c *Controller) End() error {
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
// State integrity over audit completeness: the transition still
// completes. Surfaced for the operator; no auto-retry in M1.
log.Printf("session: audit append failed: %v", err)
log.Printf("focus: audit append failed: %v", err)
}
}
c.runtimeState = next
@@ -328,7 +331,7 @@ func (c *Controller) End() error {
return c.persistLocked()
}
func (c *Controller) buildSummaryLocked() store.SessionSummary {
func (c *Mode) buildSummaryLocked() store.SessionSummary {
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
for k, d := range c.stats.Buckets {
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
@@ -1,4 +1,4 @@
package session
package focus
import (
"context"
@@ -11,15 +11,15 @@ import (
"testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/store"
"antidrift/internal/tasks"
"keel/internal/ai"
"keel/internal/evidence"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"keel/internal/tasks"
)
func newTestController(t *testing.T) (*Controller, string) {
func newTestController(t *testing.T) (*Mode, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "state.json")
c, err := New(path)
@@ -271,7 +271,7 @@ func (f *fakeCoach) grounding() string {
}
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
func waitCoachStatus(t *testing.T, c *Mode, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -442,7 +442,7 @@ func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title str
return f.verdict, f.err
}
func waitDriftStatus(t *testing.T, c *Controller, want string) State {
func waitDriftStatus(t *testing.T, c *Mode, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -456,12 +456,12 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State {
return State{}
}
func startActive(t *testing.T, c *Controller, allowed []string) {
func startActive(t *testing.T, c *Mode, allowed []string) {
t.Helper()
startActiveLevel(t, c, allowed, domain.EnforcementWarn)
}
func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) {
func startActiveLevel(t *testing.T, c *Mode, allowed []string, level domain.EnforcementLevel) {
t.Helper()
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
@@ -752,7 +752,7 @@ func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, er
return f(ctx, c, t)
}
func waitNudge(t *testing.T, c *Controller, want string) {
func waitNudge(t *testing.T, c *Mode, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -963,8 +963,10 @@ func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return f.list, f.err
}
func (f *fakeProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
func waitTasksStatus(t *testing.T, c *Controller, want string) State {
func waitTasksStatus(t *testing.T, c *Mode, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -1186,7 +1188,7 @@ func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile,
}
// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s.
func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State {
func waitKnowledgeStatus(t *testing.T, c *Mode, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -1289,7 +1291,7 @@ func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai
}
// waitReflectionStatus polls until the reflection view reaches want, or fails after 2s.
func waitReflectionStatus(t *testing.T, c *Controller, want string) State {
func waitReflectionStatus(t *testing.T, c *Mode, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -1303,7 +1305,7 @@ func waitReflectionStatus(t *testing.T, c *Controller, want string) State {
return State{}
}
func driveToReview(t *testing.T, c *Controller) {
func driveToReview(t *testing.T, c *Mode) {
t.Helper()
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
+82
View File
@@ -0,0 +1,82 @@
package focus
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"keel/internal/evidence"
"keel/internal/mode"
"keel/internal/mode/focus/domain"
)
// Compile-time assertions: focus.Mode must satisfy the harness contracts.
var _ mode.Mode = (*Mode)(nil)
var _ mode.EvidenceConsumer = (*Mode)(nil)
var _ mode.Expirer = (*Mode)(nil)
// Kind identifies this mode to the harness.
func (c *Mode) Kind() string { return "focus" }
var errBadPayload = errors.New("focus: invalid command payload")
type commitmentPayload struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
Enforce bool `json:"enforce"`
}
// Command maps surface command names onto the existing focus transitions.
func (c *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
switch name {
case "planning":
return c.EnterPlanning()
case "coach":
var r struct {
Intent string `json:"intent"`
}
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
return c.RequestCoach(r.Intent)
case "commitment":
var r commitmentPayload
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
level := domain.EnforcementWarn
if r.Enforce {
level = domain.EnforcementBlock
}
return c.StartManualCommitment(r.NextAction, r.SuccessCondition,
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
case "complete":
return c.Complete()
case "end":
return c.End()
case "refocus":
return c.Refocus()
case "ontask":
return c.OnTask()
default:
return fmt.Errorf("focus: unknown command %q", name)
}
}
// View returns the focus state projection (the existing State shape).
func (c *Mode) View() any { return c.State() }
// Active is true from Planning through Review. End() drives the runtime back to
// Locked, which the harness reads as "done" and releases.
func (c *Mode) Active() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.runtimeState != domain.RuntimeLocked
}
// OnWindow forwards the sensor stream to the existing evidence recorder.
func (c *Mode) OnWindow(w evidence.WindowSnapshot) { c.RecordWindow(w) }
@@ -1,14 +1,14 @@
package session
package focus
import (
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/knowledge"
"antidrift/internal/store"
"antidrift/internal/tasks"
"context"
"errors"
"fmt"
"keel/internal/ai"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"keel/internal/tasks"
"strings"
"time"
)
@@ -58,7 +58,7 @@ const (
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach) {
func (c *Mode) SetCoach(coach ai.Coach) {
c.mu.Lock()
c.coach = coach
c.mu.Unlock()
@@ -66,7 +66,7 @@ func (c *Controller) SetCoach(coach ai.Coach) {
// resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu.
func (c *Controller) resetCoachLocked() {
func (c *Mode) resetCoachLocked() {
c.coachStatus = coachIdle
c.coachProposal = nil
c.coachErr = ""
@@ -75,47 +75,16 @@ func (c *Controller) resetCoachLocked() {
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent.
func (c *Controller) SetTasks(p tasks.Provider) {
func (c *Mode) SetTasks(p tasks.Provider) {
c.mu.Lock()
c.tasksProvider = p
c.mu.Unlock()
}
// runFetchAsync launches a generation-guarded background fetch. The caller has
// already captured its dependencies and (for the *Locked callers) holds c.mu;
// this method only spawns the goroutine, which re-acquires the lock itself.
//
// Closure contract:
// - fetch performs the I/O and runs with NO lock held; it must not touch c
// fields without taking c.mu itself.
// - stale runs under the re-acquired c.mu and returns true to DISCARD the
// result (e.g. a newer generation superseded this one).
// - apply runs under the re-acquired c.mu and records the result (persisting
// itself when the role requires it); it must NOT call c.notify — the helper
// owns the post-unlock notify, and notify self-locks, so calling it under
// c.mu would deadlock.
//
// On a non-stale completion the helper notifies after releasing the lock.
func (c *Controller) runFetchAsync(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fetch(ctx)
c.mu.Lock()
if stale() {
c.mu.Unlock()
return
}
apply()
c.mu.Unlock()
c.notify()
}()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu.
func (c *Controller) startTasksFetchLocked() {
func (c *Mode) startTasksFetchLocked() {
c.tasksList = nil
if c.tasksProvider == nil {
c.tasksStatus = tasksIdle
@@ -127,7 +96,7 @@ func (c *Controller) startTasksFetchLocked() {
provider := c.tasksProvider
var list []tasks.Task
var err error
c.runFetchAsync(tasksTimeout,
c.async.Run(tasksTimeout,
func(ctx context.Context) { list, err = provider.Today(ctx) },
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
func() {
@@ -143,7 +112,7 @@ func (c *Controller) startTasksFetchLocked() {
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded.
func (c *Controller) SetKnowledge(s knowledge.Source) {
func (c *Mode) SetKnowledge(s knowledge.Source) {
c.mu.Lock()
c.knowledgeSrc = s
c.mu.Unlock()
@@ -152,7 +121,7 @@ func (c *Controller) SetKnowledge(s knowledge.Source) {
// SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default.
func (c *Controller) SetKnowledgePath(path string) {
func (c *Mode) SetKnowledgePath(path string) {
c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning
@@ -169,7 +138,7 @@ func (c *Controller) SetKnowledgePath(path string) {
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu.
func (c *Controller) startKnowledgeFetchLocked() {
func (c *Mode) startKnowledgeFetchLocked() {
c.knowledgeText = ""
c.knowledgeChars = 0
if c.knowledgeSrc == nil {
@@ -183,7 +152,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
path := c.knowledgePath
var prof knowledge.Profile
var err error
c.runFetchAsync(knowledgeTimeout,
c.async.Run(knowledgeTimeout,
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
func() {
@@ -210,7 +179,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward.
func (c *Controller) SetReviewer(r ai.Reviewer) {
func (c *Mode) SetReviewer(r ai.Reviewer) {
c.mu.Lock()
c.reviewer = r
c.mu.Unlock()
@@ -223,7 +192,7 @@ func (c *Controller) SetReviewer(r ai.Reviewer) {
// review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu.
func (c *Controller) startReflectionFetchLocked() {
func (c *Mode) startReflectionFetchLocked() {
c.reflectionRecap = ""
c.carryForward = ""
if c.reviewer == nil {
@@ -245,7 +214,7 @@ func (c *Controller) startReflectionFetchLocked() {
history := buildReflectionHistory(c.auditPath)
var refl ai.Reflection
var err error
c.runFetchAsync(reflectionTimeout,
c.async.Run(reflectionTimeout,
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
func() bool { return gen != c.reflectionGen },
func() {
@@ -267,7 +236,7 @@ func (c *Controller) startReflectionFetchLocked() {
// totals, and a top-N on-task list and top-N off-task list. The split fields are
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
// set (End clears them, but enterReview runs before End).
func (c *Controller) buildReflectionFinishedLocked() string {
func (c *Mode) buildReflectionFinishedLocked() string {
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
@@ -340,7 +309,7 @@ func buildReflectionHistory(auditPath string) string {
// composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu.
func (c *Controller) composedGroundingLocked() string {
func (c *Mode) composedGroundingLocked() string {
g := c.knowledgeText
if c.carryForward != "" {
if g != "" {
@@ -354,7 +323,7 @@ func (c *Controller) composedGroundingLocked() string {
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted.
func (c *Controller) RequestCoach(intent string) error {
func (c *Mode) RequestCoach(intent string) error {
c.mu.Lock()
if c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
@@ -380,7 +349,7 @@ func (c *Controller) RequestCoach(intent string) error {
var prop ai.Proposal
var err error
c.runFetchAsync(coachTimeout,
c.async.Run(coachTimeout,
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
func() {
@@ -5,7 +5,7 @@ package statemachine
import (
"fmt"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
@@ -3,7 +3,7 @@ package statemachine
import (
"testing"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
@@ -1,8 +1,8 @@
package session
package focus
import (
"antidrift/internal/evidence"
"antidrift/internal/store"
"keel/internal/evidence"
"keel/internal/store"
"time"
)
@@ -27,7 +27,7 @@ type EvidenceStats struct {
// applyEvent advances stats by one observation: it credits the prior segment to
// the prior bucket, counts a context switch on key change, and records the new
// current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
func (c *Mode) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast {
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
}
@@ -47,7 +47,7 @@ func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
// before the state transition). idle/pending route to unclassified: honest, never
// falsely on-task. Caller holds mu.
func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
func (c *Mode) creditLocked(k bucketKey, d time.Duration) {
c.stats.Buckets[k] += d
switch c.driftStatus {
case driftOnTask:
@@ -60,7 +60,7 @@ func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
}
// replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Controller) replayStats(sessionID string) {
func (c *Mode) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID)
if err != nil || len(events) == 0 {
c.stats = &EvidenceStats{
@@ -1,7 +1,7 @@
package session
package focus
import (
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
"sort"
"time"
)
@@ -102,7 +102,7 @@ type State struct {
Drift *DriftView `json:"drift,omitempty"`
}
func (c *Controller) stateLocked() State {
func (c *Mode) stateLocked() State {
st := State{RuntimeState: c.runtimeState}
if c.commitment != nil {
view := &CommitmentView{
+44
View File
@@ -0,0 +1,44 @@
// Package mode defines the contract the harness uses to host a single
// collect→brain→act unit. Focus and off-screen are implementations.
package mode
import (
"context"
"encoding/json"
"time"
"keel/internal/evidence"
)
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
// Kind identifies the mode ("focus", "offscreen").
Kind() string
// Command routes a surface-issued command into the mode. It returns an
// error for illegal/invalid commands; the web layer maps it to HTTP.
Command(ctx context.Context, name string, payload json.RawMessage) error
// View projects the mode's current state for surfaces (JSON-marshalable).
View() any
// Active reports whether the mode still holds the harness. When it returns
// false the harness releases the mode and goes idle.
Active() bool
}
// EvidenceConsumer is implemented by modes that read the window sensor stream.
type EvidenceConsumer interface {
OnWindow(evidence.WindowSnapshot)
}
// Expirer is implemented by modes with a server-authoritative deadline. Expire
// takes no context to match focus's existing Expire() error method, so focus
// satisfies this interface with zero changes to its tested surface.
type Expirer interface {
Deadline() time.Time
Expire() error
}
// Envelope is the surfacing wrapper: the active mode's kind plus its View().
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"`
}
+104
View File
@@ -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))
}
}
+235
View File
@@ -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[:])
}
+248
View File
@@ -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")
}
}
+45
View File
@@ -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 }
+22
View File
@@ -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")
}
}
+51 -8
View File
@@ -1,5 +1,5 @@
// Package settings persists the daemon's user-editable configuration as a single
// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a
// JSON file (~/.keel/settings.json), parallel to the store snapshot. It is a
// leaf type package: it imports nothing else in the app so any layer may depend
// on the Settings value without pulling in adapters.
package settings
@@ -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,
}
}
+60 -8
View File
@@ -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)
}
}
+82 -21
View File
@@ -1,5 +1,5 @@
// Package statusfile mirrors the controller's runtime status into a single-line
// file (~/.antidrift_status) so an external consumer — a window-manager status
// Package statusfile mirrors the harness's runtime status into a single-line
// file (~/.keel_status) so an external consumer — a window-manager status
// bar — can display it with a plain file read.
package statusfile
@@ -9,20 +9,22 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
"antidrift/internal/domain"
"antidrift/internal/session"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
)
// statusFileName is the file written under the user's home directory.
const statusFileName = ".antidrift_status"
const statusFileName = ".keel_status"
// interval is how often the writer re-renders. Minute granularity is enough for
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
const interval = time.Minute
// DefaultPath resolves ~/.antidrift_status.
// DefaultPath resolves ~/.keel_status.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
@@ -31,10 +33,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
}
+67 -27
View File
@@ -8,17 +8,28 @@ import (
"testing"
"time"
"antidrift/internal/domain"
"antidrift/internal/session"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
)
func activeState(deadline int64, driftStatus, nudge string) session.State {
st := session.State{
// focusEnv wraps a focus.State in the harness envelope the writer now consumes.
func focusEnv(st focus.State) mode.Envelope {
return mode.Envelope{ActiveMode: "focus", Mode: st}
}
// focusState builds a focus.State with the given runtime state.
func focusState(rt domain.RuntimeState) focus.State {
return focus.State{RuntimeState: rt}
}
func activeState(deadline int64, driftStatus, nudge string) focus.State {
st := focus.State{
RuntimeState: domain.RuntimeActive,
Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline},
Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline},
}
if driftStatus != "" || nudge != "" {
st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge}
st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge}
}
return st
}
@@ -29,25 +40,33 @@ func TestRender(t *testing.T) {
cases := []struct {
name string
st session.State
env mode.Envelope
want string
}{
{"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""},
{"empty runtime", session.State{}, ""},
{"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"},
{"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"},
{"active ontask", activeState(in24m, "ontask", ""), "● 24m"},
{"active no drift view", activeState(in24m, "", ""), "● 24m"},
{"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"},
{"active nudge", activeState(in24m, "ontask", "wandered off"), " 24m ·?"},
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"},
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"},
{"active past deadline", activeState(now.Add(-5*time.Minute).Unix(), "ontask", ""), "● 0m"},
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"},
{"idle", mode.Envelope{}, "idle"},
{"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""},
{"empty focus runtime", focusEnv(focus.State{}), ""},
{"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"},
{"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"},
{"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"},
{"active no drift view", focusEnv(activeState(in24m, "", "")), " 24m"},
{"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"},
{"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), " 24m ·?"},
{"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"},
{"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"},
{"active past deadline", focusEnv(activeState(now.Add(-5*time.Minute).Unix(), "ontask", "")), "● 0m"},
{"active partial minute rounds up", focusEnv(activeState(now.Add(10*time.Second).Unix(), "ontask", "")), "● 1m"},
{"unknown kind falls back to kind name", mode.Envelope{ActiveMode: "house"}, "house"},
{"focus with wrong payload falls back", mode.Envelope{ActiveMode: "focus", Mode: "bogus"}, "focus"},
{"offscreen proposed", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "call mum"}}, "off-screen: call mum"},
{"offscreen pending", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "pending"}}, "off-screen: thinking…"},
{"offscreen error", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "error", "error": "boom"}}, "off-screen: ⚠"},
{"offscreen proposed no action", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed"}}, "off-screen: thinking…"},
{"offscreen wrong payload", mode.Envelope{ActiveMode: "offscreen", Mode: "bogus"}, "off-screen: thinking…"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Render(tc.st, now); got != tc.want {
if got := Render(tc.env, "", now); got != tc.want {
t.Errorf("Render() = %q, want %q", got, tc.want)
}
})
@@ -57,8 +76,8 @@ func TestRender(t *testing.T) {
func TestWriterWritesAndDedups(t *testing.T) {
path := filepath.Join(t.TempDir(), "status")
now := time.Unix(1_000_000, 0)
state := session.State{RuntimeState: domain.RuntimePlanning}
w := NewWriter(path, func() session.State { return state })
env := focusEnv(focusState(domain.RuntimePlanning))
w := NewWriter(path, func() mode.Envelope { return env }, 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
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"os"
"path/filepath"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
// Snapshot is the persisted current state of the daemon.
@@ -29,13 +29,13 @@ type Snapshot struct {
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
}
// DefaultPath returns ~/.antidrift/state.json.
// DefaultPath returns ~/.keel/state.json.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".antidrift", "state.json"), nil
return filepath.Join(home, ".keel", "state.json"), nil
}
// Load reads a snapshot. A missing file yields a default Locked snapshot.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestLoadMissingFileReturnsLocked(t *testing.T) {
+15 -4
View File
@@ -31,7 +31,8 @@ func execRunner(ctx context.Context, name string, args ...string) ([]byte, error
// (`am --json`, no subcommand) and parses the open tasks due today or earlier.
type Marvin struct {
cmd string
args []string
base []string // configured prefix after cmd, without the "--json" sentinel
args []string // read-path argv: base + ["--json"]
run runner
}
@@ -43,9 +44,9 @@ func NewMarvin(command string) *Marvin {
if len(fields) == 0 {
fields = []string{"am"}
}
args := append([]string{}, fields[1:]...)
args = append(args, "--json")
return &Marvin{cmd: fields[0], args: args, run: execRunner}
base := append([]string{}, fields[1:]...)
args := append(append([]string{}, base...), "--json")
return &Marvin{cmd: fields[0], base: base, args: args, run: execRunner}
}
// Today returns the open tasks due today or earlier, or an error if the CLI
@@ -58,6 +59,16 @@ func (m *Marvin) Today(ctx context.Context) ([]Task, error) {
return parse(out)
}
// Create adds a new task via ampy's `add` subcommand, shelling out through the
// same configured prefix as the read path so the add lands in the same CRDT
// store the reads observe. Only the title is sent in v1. It returns the CLI
// error on failure.
func (m *Marvin) Create(ctx context.Context, t Task) error {
args := append(append([]string{}, m.base...), "add", t.Title, "--json")
_, err := m.run(ctx, m.cmd, args...)
return err
}
type rawTask struct {
ID string `json:"id"`
Title string `json:"title"`
+33
View File
@@ -3,6 +3,7 @@ package tasks
import (
"context"
"errors"
"strings"
"testing"
)
@@ -106,3 +107,35 @@ func TestTodayPropagatesError(t *testing.T) {
t.Fatal("want error from failing runner")
}
}
func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) {
m := NewMarvin("uv run am")
var gotName string
var gotArgs []string
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
gotName, gotArgs = name, args
return []byte(`{"id":"task-1"}`), nil
}
if err := m.Create(context.Background(), NewTask("buy groceries")); err != nil {
t.Fatalf("Create: %v", err)
}
// Same prefix the read path shells out through, so add goes through the
// same ampy entrypoint and preserves its CRDT bookkeeping.
if gotName != "uv" {
t.Fatalf("runner name = %q, want %q", gotName, "uv")
}
joined := strings.Join(gotArgs, " ")
if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") {
t.Fatalf("args = %v, want marvin add with title", gotArgs)
}
}
func TestCreatePropagatesError(t *testing.T) {
m := NewMarvin("am")
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
return nil, errors.New("am add failed")
}
if err := m.Create(context.Background(), NewTask("buy groceries")); err == nil {
t.Fatal("want error from failing runner")
}
}
+5 -1
View File
@@ -13,7 +13,11 @@ type Task struct {
}
// Provider answers "what should I be doing?" — the open tasks due today or
// earlier.
// earlier — and lets callers add new tasks.
type Provider interface {
Today(ctx context.Context) ([]Task, error)
Create(ctx context.Context, t Task) error
}
// NewTask builds a minimal task for creation (title only in v1).
func NewTask(title string) Task { return Task{Title: title} }
+15 -2
View File
@@ -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"))
+67 -7
View File
@@ -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)
}
}
+29
View File
@@ -88,6 +88,10 @@ input[type="checkbox"] {
.btn-ghost { background: var(--line); color: var(--ink); }
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
/* Large tap targets for the launcher and off-screen primary actions. */
.btn-big { display: block; width: 100%; margin-top: 12px; padding: 14px 16px; font-size: 16px; }
.launcher .btn-big:first-of-type { margin-top: 4px; }
/* Drift/nudge actions live in the status band; lay them out below the text. */
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
@@ -176,3 +180,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
View File
@@ -2,6 +2,7 @@ const app = document.getElementById('app');
const view = document.getElementById('view');
let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows
let renderedMode = null; // the active_mode the DOM currently shows
let dismissedNudge = null; // text of the nudge the user has dismissed
function post(path, body) {
@@ -86,9 +87,9 @@ function updateActiveDrift(state) {
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
</div>`;
document.getElementById('refocus').onclick = () => post('/refocus');
document.getElementById('ontask').onclick = () => post('/ontask');
document.getElementById('enddrift').onclick = () => post('/complete');
document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
return;
}
@@ -223,7 +224,7 @@ function updatePlanningReflection(refl) {
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
}
function render(state) {
function renderFocus(state) {
setStateAttr(state);
const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') {
@@ -248,7 +249,7 @@ function render(state) {
<p class="meta">No active commitment.</p>
<button id="plan" class="btn btn-primary">Start planning</button>
</div>`;
document.getElementById('plan').onclick = () => post('/planning');
document.getElementById('plan').onclick = () => post('/mode/focus/start');
} else if (rs === 'planning') {
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
@@ -276,7 +277,7 @@ function render(state) {
mins = document.getElementById('mins'), start = document.getElementById('start');
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
[na, sc, mins].forEach(el => el.oninput = check);
start.onclick = () => post('/commitment', {
start.onclick = () => post('/mode/command/commitment', {
next_action: na.value.trim(),
success_condition: sc.value.trim(),
timebox_secs: Math.round(+mins.value * 60),
@@ -286,7 +287,7 @@ function render(state) {
});
document.getElementById('sharpen').onclick = () => {
const intent = document.getElementById('intent').value.trim();
if (intent) post('/coach', { intent });
if (intent) post('/mode/command/coach', { intent });
};
updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks);
@@ -304,7 +305,7 @@ function render(state) {
</div>
${evidenceBlock(state.evidence)}
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
document.getElementById('done').onclick = () => post('/complete');
document.getElementById('done').onclick = () => post('/mode/command/complete');
const t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick();
@@ -322,16 +323,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 -2
View File
@@ -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
View File
@@ -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
View File
@@ -9,26 +9,46 @@ import (
"testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/tasks"
"keel/internal/ai"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
"keel/internal/settings"
"keel/internal/tasks"
"github.com/gin-gonic/gin"
)
func newTestServer(t *testing.T) *Server {
// newTestServer builds a harness around a freshly-constructed focus mode and
// returns the server plus the mode so tests can inject per-role stubs (the old
// controller's Set* API lives on focus.Mode) and assert on the typed focus
// state. The mode is registered behind a factory that wires the harness change
// notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on
// every focus change. The mode starts Locked; tests drive it through the
// command routes (e.g. POST /mode/command/planning), mirroring the original
// suite which POSTed /planning first.
func newTestServer(t *testing.T) (*Server, *focus.Mode) {
t.Helper()
gin.SetMode(gin.TestMode)
path := filepath.Join(t.TempDir(), "state.json")
ctrl, err := session.New(path)
m, err := focus.New(path)
if err != nil {
t.Fatalf("controller: %v", err)
t.Fatalf("focus.New: %v", err)
}
return NewServer(ctrl)
h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{
"focus": func(svc harness.Services) mode.Mode {
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m
},
})
s := NewServer(h)
if err := h.Start("focus"); err != nil {
t.Fatalf("harness.Start(focus): %v", err)
}
return s, m
}
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
@@ -40,53 +60,73 @@ func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRec
return w
}
// fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for
// the real-factory parity test without reaching any model.
type fakeBackend struct{}
func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil }
func (fakeBackend) Name() string { return "fake" }
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
if m.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", m.State().RuntimeState)
}
}
func TestCommitmentRejectsInvalidInput(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
w := post(t, r, "/commitment", body)
w := post(t, r, "/mode/command/commitment", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestIllegalTransitionReturns409(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
// /complete from Locked is illegal.
w := post(t, r, "/complete", "")
w := post(t, r, "/mode/command/complete", "")
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
// TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with
// the focus state nested under "mode" and the kind under "active_mode".
func TestEnvelopeShape(t *testing.T) {
s, _ := newTestServer(t)
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("payload missing active_mode: %s", js)
}
if !strings.Contains(js, `"mode"`) {
t.Fatalf("payload missing nested mode object: %s", js)
}
}
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// A focus update should appear in the serialized state.
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}})
// A focus update should appear in the serialized state envelope.
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
js := s.stateJSON()
if !strings.Contains(js, `"evidence"`) {
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
}
func TestLockedStateHasNullEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
// End any active session to return to Locked; a freshly-started focus mode is
// already Locked (Start does not auto-plan in this harness wiring).
if m.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState)
}
js := s.stateJSON()
if !strings.Contains(js, `"evidence":null`) {
t.Fatalf("locked payload should have null evidence: %s", js)
@@ -115,60 +160,60 @@ func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Prop
}
func TestCoachRouteHappyPath(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
s, m := newTestServer(t)
m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" {
if cv := m.State().Coach; cv != nil && cv.Status == "ready" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach)
t.Fatalf("coach never reached ready: %+v", m.State().Coach)
}
func TestCoachRouteOutsidePlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{})
s, m := newTestServer(t)
m.SetCoach(stubCoach{})
r := s.Router()
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCoachRouteInvalidJSON(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCoachRouteUnavailableDegrades(t *testing.T) {
s := newTestServer(t) // no SetCoach
s, m := newTestServer(t) // no SetCoach
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
t.Fatalf("unavailable coach should still 200, got %d", w.Code)
}
if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" {
if cv := m.State().Coach; cv == nil || cv.Status != "error" {
t.Fatalf("want error status, got %+v", cv)
}
}
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest {
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
}
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest {
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
}
}
@@ -180,22 +225,22 @@ func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles [
}
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
s, m := newTestServer(t)
m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// Two distinct on-task titles trip the nudge (history >= 2; first call has no
// debounce to wait on).
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
if d := m.State().Drift; d != nil && d.Nudge != "" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
}
func TestServesStaticAssets(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
cases := []struct {
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return s.list, nil
}
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
s, m := newTestServer(t)
m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
if tv := m.State().Tasks; tv != nil && tv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
}
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
}
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
t.Fatalf("commitment did not carry allowed classes: %#v", got)
}
// Now Active: overrides succeed.
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK {
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
}
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK {
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
}
}
@@ -298,15 +345,15 @@ func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, e
}
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}})
s, m := newTestServer(t)
m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" {
if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -329,20 +376,20 @@ func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.
}
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
s, m := newTestServer(t)
m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" {
if rv := m.State().Reflection; rv != nil && rv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
t.Fatalf("review payload missing recap: %s", js)
}
// End -> Locked -> Planning: the carry-forward should surface on planning.
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
// End -> Locked: the harness releases the focus mode and goes idle. Re-start
// the mode (re-adopting the same in-memory focus, carry-forward intact) and
// re-enter Planning; the carry-forward should surface there.
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
js2 := s.stateJSON()
@@ -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 -1
View File
@@ -1,4 +1,4 @@
// Package winapi is the Windows Win32 binding layer for AntiDrift's OS ports.
// Package winapi is the Windows Win32 binding layer for Keel's OS ports.
// The syscall-bound code lives in windows-tagged files; this untagged file
// holds the pure path logic so it builds and is tested on every platform.
package winapi