Compare commits
49 Commits
14ec2b6ea8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb6adf7ef | |||
| b36964a427 | |||
| 6c81a0d4f8 | |||
| aaed1ad265 | |||
| fa178a1fd2 | |||
| ef845ce463 | |||
| 4b2cb4242f | |||
| 7507cc6d28 | |||
| c963895599 | |||
| af3a0e2ac8 | |||
| b37ee40dd2 | |||
| 2690fdd513 | |||
| 703218658b | |||
| 607ad74def | |||
| aac3173874 | |||
| 9d7786c3e6 | |||
| 682eb603fa | |||
| 6d296bf743 | |||
| c855731c39 | |||
| bd2b8a41fd | |||
| 16b44c9e90 | |||
| 5bdb98c1a7 | |||
| 67f91442a4 | |||
| 3a4f498f86 | |||
| 7d4f9ca4a8 | |||
| 582af0d68b | |||
| b422b6deb6 | |||
| 3386405c7c | |||
| 730ffe33ce | |||
| 7d69a1f320 | |||
| 258de2c14b | |||
| f9e580ff7e | |||
| 13633ffabf | |||
| 9012d5ddc6 | |||
| 91a3fb29c2 | |||
| 9546999acb | |||
| d45a01eca5 | |||
| b6c7933f16 | |||
| f37e295166 | |||
| d149736946 | |||
| 0daf8c2849 | |||
| 0f47d2dbf0 | |||
| 0ac1511aac | |||
| 7d62af0750 | |||
| e1194be3c2 | |||
| f79c149039 | |||
| 4ea8aed04b | |||
| 885b0f9224 | |||
| e0ea1eca83 |
+1
-11
@@ -1,16 +1,6 @@
|
|||||||
# Generated by Cargo will have compiled files and executables
|
|
||||||
debug/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
|
||||||
**/*.rs.bk
|
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
||||||
*.pdb
|
|
||||||
|
|
||||||
# Local brainstorming companion artifacts
|
# Local brainstorming companion artifacts
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
/antidriftd
|
/keeld
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Keel — Agent Context
|
||||||
|
|
||||||
|
You are working in **Keel**, Felix's human-harness. Read this, then treat
|
||||||
|
`docs/keel-architecture.md` as the source of truth for anything architectural.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
The inversion of an agent harness: instead of a harness watching a model work, a
|
||||||
|
model (the brain) helps observe and steer a *human*. Felix is the observed
|
||||||
|
subject; the brain is the operator. The thing we build is **the harness** — the
|
||||||
|
framework that collects Felix's real state, hands it to a swappable brain, and
|
||||||
|
acts on the reply.
|
||||||
|
|
||||||
|
- **Brain** — swappable, rented compute: `claude` / `codex` / **Hermes** (real,
|
||||||
|
on another machine). Not the point; interchangeable behind one interface.
|
||||||
|
- **Storage** — **ActivityWatch** buckets (`keel.state`, `keel.events`). No new
|
||||||
|
DB / event store. Buckets hold *pointers*, not copies.
|
||||||
|
- **Frame** — `~/owc` (values, goals, `resources/bug-*.md` life-bugs). Reference,
|
||||||
|
never duplicate.
|
||||||
|
- **Surfaces** — a phone-friendly web UI + the WM status bar (`~/.keel_status`).
|
||||||
|
- **Effectors** — gated writes (Marvin tasks, capture, AntiDrift enforce; later
|
||||||
|
Beeminder datapoints, `~/owc` notes). Start propose-and-confirm; trend toward
|
||||||
|
autonomy. The gate is per-effector config — Felix's agency-vs-cage dial.
|
||||||
|
|
||||||
|
## What runs today
|
||||||
|
|
||||||
|
**AntiDrift**, Keel's first *mode*: a Go focus daemon (ports-and-adapters) —
|
||||||
|
`evidence.Source` (perception) · `tasks.Provider` (Marvin) · `knowledge.Source`
|
||||||
|
(the `~/owc`-derived profile) · `ai.Backend` (the brain) · `enforce.Guard`
|
||||||
|
(window-minimize). The cockpit work is **loosening its session controller** into a
|
||||||
|
general *collect → brain → act* loop where a focus-session is one mode among many.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/keeld # local web UI at http://localhost:7777
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming / rename status
|
||||||
|
|
||||||
|
The rename is **complete**. Directory, git repo, Go module, binary, runtime, and
|
||||||
|
env are all `keel`: module `keel`, binary `keeld`, runtime `~/.keel/`,
|
||||||
|
`KEEL_*` env. If you have a live ledger under the old `~/.antidrift/` directory,
|
||||||
|
move it manually to `~/.keel/` — there is no automated migration tool.
|
||||||
|
|
||||||
|
## How to work here
|
||||||
|
|
||||||
|
- **Reuse before building.** Read the code before claiming something is missing.
|
||||||
|
AntiDrift already is the harness skeleton; widen it, don't rebuild.
|
||||||
|
- **Reference, don't duplicate.** `~/owc` and each tool's store stay source of
|
||||||
|
truth; Keel never becomes a hidden second source.
|
||||||
|
- **Ship visible proof.** Every session ends with a real artifact — reframing and
|
||||||
|
planning are not progress. Felix's named failure mode is tooling-as-avoidance;
|
||||||
|
resist it.
|
||||||
|
- **Concise coaching.** The status line is one signal / one action / one question.
|
||||||
|
Not "nice summaries" — that was explicitly rejected.
|
||||||
|
- **Local-first; mind secrets.** The operator runs as the full Unix user with no
|
||||||
|
sandbox; window titles and on-disk keys can leak. Filter at the assembler.
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
|
||||||
|
- `docs/keel-architecture.md` — authoritative architecture (components, data flow,
|
||||||
|
AW-as-storage, the open questions in §8).
|
||||||
|
- `docs/superpowers/` — AntiDrift focus-mode feature specs/plans (historical).
|
||||||
|
- `README.md` — project front door.
|
||||||
@@ -1,19 +1,45 @@
|
|||||||
# AntiDrift
|
# Keel
|
||||||
|
|
||||||
A personal focus operating system: treat each work session as an explicit
|
A **human-harness** that helps Felix hold course toward his higher goals: it
|
||||||
commitment (next action, success condition, timebox), and make drift visible.
|
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.
|
||||||
|
|
||||||
This is the Go reimagining. The original Rust implementation is preserved under
|
> **Architecture:** [`docs/keel-architecture.md`](docs/keel-architecture.md) is the
|
||||||
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
|
> 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
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/antidriftd
|
go run ./cmd/keeld
|
||||||
```
|
```
|
||||||
|
|
||||||
The daemon serves a local web UI at http://localhost:7777 and opens your
|
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
|
## Test
|
||||||
|
|
||||||
@@ -41,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.
|
by design, while the knowledge profile still does not.
|
||||||
|
|
||||||
M6 (knowledge port): the planning coach now sharpens intents against who you
|
M6 (knowledge port): the planning coach now sharpens intents against who you
|
||||||
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable
|
actually are. A single profile file (`~/.keel/knowledge.md`, overridable
|
||||||
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
via `KEEL_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
||||||
values, what counts as good work — and the daemon loads it asynchronously on
|
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
|
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
|
coach prompt only; the drift judge and nudge are untouched, and the profile text
|
||||||
@@ -70,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"
|
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
|
||||||
into a structured commitment (next action, success condition, timebox) by an
|
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
|
coach runs asynchronously and degrades gracefully — manual planning always
|
||||||
works.
|
works.
|
||||||
|
|
||||||
M1 (evidence & audit): active-window tracking, two-tier evidence store
|
M1 (evidence & audit): active-window tracking, two-tier evidence store
|
||||||
(disposable per-session raw log + permanent hash-chained session summaries),
|
(disposable per-session raw log + permanent hash-chained session summaries),
|
||||||
and live SSE updates. Live drift judgment and the ambient nudge arrive in later
|
and live SSE updates. Live drift judgment and the ambient nudge arrived in later
|
||||||
milestones (see the roadmap in
|
milestones (M3 and M3.5 above; the original roadmap spec has since been removed —
|
||||||
`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`).
|
code and git history are the record).
|
||||||
|
|||||||
@@ -1,138 +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 {
|
|
||||||
go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background())
|
|
||||||
log.Printf("status: writing %s", statusPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl.SetGuard(enforce.NewGuard())
|
|
||||||
log.Printf("enforce: window-minimize guard")
|
|
||||||
|
|
||||||
src := evidence.NewSource()
|
|
||||||
go src.Watch(context.Background(), ctrl.RecordWindow)
|
|
||||||
|
|
||||||
go openBrowser("http://" + addr)
|
|
||||||
|
|
||||||
log.Printf("antidriftd listening on http://%s", addr)
|
|
||||||
if err := srv.Router().Run(addr); err != nil {
|
|
||||||
log.Fatalf("server: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openBrowser best-effort launches the default browser after a short delay so
|
|
||||||
// the server is listening first. Failures are logged, not fatal.
|
|
||||||
func openBrowser(url string) {
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
var cmd string
|
|
||||||
var args []string
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "linux":
|
|
||||||
cmd, args = "xdg-open", []string{url}
|
|
||||||
case "darwin":
|
|
||||||
cmd, args = "open", []string{url}
|
|
||||||
case "windows":
|
|
||||||
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
|
||||||
default:
|
|
||||||
log.Printf("open browser manually: %s", url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := exec.Command(cmd, args...).Start(); err != nil {
|
|
||||||
log.Printf("could not open browser (%v); visit %s", err, url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Command keeld is the Keel daemon: it serves a local web UI and drives the
|
||||||
|
// harness — the general collect→brain→act loop that hosts one mode at a time
|
||||||
|
// (focus or offscreen).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/ambient"
|
||||||
|
"keel/internal/aw"
|
||||||
|
"keel/internal/enforce"
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/harness"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
|
"keel/internal/mode"
|
||||||
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/offscreen"
|
||||||
|
"keel/internal/notify"
|
||||||
|
"keel/internal/settings"
|
||||||
|
"keel/internal/statusfile"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
"keel/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addr = "localhost:7777"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolve home dir: %v", err)
|
||||||
|
}
|
||||||
|
root := filepath.Join(home, ".keel")
|
||||||
|
|
||||||
|
// Resolve and load settings, seeding from the legacy KEEL_* env vars on
|
||||||
|
// first run. After first run the file is the sole source of truth; env is
|
||||||
|
// ignored. A missing file is the first-run signal.
|
||||||
|
settingsPath, err := settings.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolve settings path: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := settings.Load(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
cfg = settings.SeedFromEnv()
|
||||||
|
if err := settings.Save(settingsPath, cfg); err != nil {
|
||||||
|
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("settings: seeded %s from environment", settingsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServices turns a Settings value into a fresh harness.Services. It
|
||||||
|
// validates the AI backend FIRST and returns an error before constructing any
|
||||||
|
// ports, so an invalid backend never half-rewires the harness. The same code
|
||||||
|
// path serves startup and live POST /settings edits.
|
||||||
|
buildServices := func(s settings.Settings) (harness.Services, error) {
|
||||||
|
backend, err := ai.NewBackend(s.AIBackend)
|
||||||
|
if err != nil {
|
||||||
|
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
|
||||||
|
}
|
||||||
|
svc := ai.NewService(backend)
|
||||||
|
|
||||||
|
// Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on
|
||||||
|
// first run, but Load returns whatever the JSON file holds on later starts.
|
||||||
|
awURL := s.AWURL
|
||||||
|
if awURL == "" {
|
||||||
|
awURL = "http://localhost:5600"
|
||||||
|
}
|
||||||
|
var mem memory.Store = memory.NewNop()
|
||||||
|
awc := aw.New(awURL)
|
||||||
|
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||||
|
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
|
||||||
|
} else {
|
||||||
|
mem = memory.NewAWStore(awc, "keel.events")
|
||||||
|
log.Printf("memory: keel.events ready at %s", awURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return harness.Services{
|
||||||
|
AI: svc,
|
||||||
|
Tasks: tasks.NewMarvin(s.MarvinCmd),
|
||||||
|
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
|
||||||
|
Enforce: enforce.NewGuard(),
|
||||||
|
Memory: mem,
|
||||||
|
Clock: time.Now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the base services at startup. A bad persisted backend should not be
|
||||||
|
// fatal: fall back to claude (always valid), persist the correction, and retry.
|
||||||
|
base, err := buildServices(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("settings: %v; falling back to claude backend", err)
|
||||||
|
cfg.AIBackend = "claude"
|
||||||
|
if err := settings.Save(settingsPath, cfg); err != nil {
|
||||||
|
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
|
||||||
|
}
|
||||||
|
base, err = buildServices(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("settings: build failed even with claude: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
|
||||||
|
log.Printf("enforce: window-minimize guard")
|
||||||
|
|
||||||
|
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
|
||||||
|
// mode builds its own file paths under Services.Dir.
|
||||||
|
withDir := func(dir string, f harness.Factory) harness.Factory {
|
||||||
|
return func(s harness.Services) mode.Mode {
|
||||||
|
s.Dir = dir
|
||||||
|
return f(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusDir := filepath.Join(root, "modes", "focus")
|
||||||
|
offscreenDir := filepath.Join(root, "modes", "offscreen")
|
||||||
|
h := harness.New(base, map[string]harness.Factory{
|
||||||
|
"focus": withDir(focusDir, focus.Factory),
|
||||||
|
"offscreen": withDir(offscreenDir, offscreen.Factory),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-adopt a live focus session restored from disk, if one was in flight.
|
||||||
|
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
|
||||||
|
// restored mode's async completions reach the harness's onChange listeners.
|
||||||
|
focusSvc := h.Services()
|
||||||
|
focusSvc.Dir = focusDir
|
||||||
|
if m, live, err := focus.Restore(focusSvc); err != nil {
|
||||||
|
log.Printf("focus: restore failed (continuing idle): %v", err)
|
||||||
|
} else if live {
|
||||||
|
if err := h.Adopt(m); err != nil {
|
||||||
|
log.Printf("focus: adopt restored session failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("focus: restored a live session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
|
||||||
|
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
|
||||||
|
// mode is active. Backend/port changes need a daemon restart to reach it; live
|
||||||
|
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
|
||||||
|
// services").
|
||||||
|
ambMode := cfg.AmbientMode
|
||||||
|
if ambMode == "" {
|
||||||
|
ambMode = settings.AmbientNotify
|
||||||
|
}
|
||||||
|
// Compute the effective cadence so the log reflects what the sentinel will
|
||||||
|
// actually use: New clamps a non-positive value (e.g. a settings.json that
|
||||||
|
// predates these fields) to a default, mirrored here for the log line.
|
||||||
|
ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second
|
||||||
|
if ambCadence <= 0 {
|
||||||
|
ambCadence = 5 * time.Minute
|
||||||
|
}
|
||||||
|
sentinel := ambient.New(ambient.Deps{
|
||||||
|
AI: base.AI,
|
||||||
|
Knowledge: base.Knowledge,
|
||||||
|
Tasks: base.Tasks,
|
||||||
|
Notifier: notify.NewNotifier(),
|
||||||
|
Memory: base.Memory,
|
||||||
|
Clock: time.Now,
|
||||||
|
ActiveMode: func() string { return h.State().ActiveMode },
|
||||||
|
}, ambCadence, ambMode)
|
||||||
|
log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence)
|
||||||
|
|
||||||
|
srv := web.NewServer(h)
|
||||||
|
srv.Init() // re-arm or expire a restored deadline-bearing session
|
||||||
|
|
||||||
|
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
|
||||||
|
sentinel.AddOnChange(srv.Broadcast)
|
||||||
|
|
||||||
|
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
|
||||||
|
// fresh harness.Services and installs it via SetServices. Per the harness
|
||||||
|
// contract this affects the NEXT mode start; an already-active mode keeps the
|
||||||
|
// services it was built with. It validates the backend before mutating, so
|
||||||
|
// POST /settings can reject an invalid backend atomically.
|
||||||
|
applyFn := func(s settings.Settings) error {
|
||||||
|
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
|
||||||
|
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
|
||||||
|
}
|
||||||
|
next, err := buildServices(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.SetServices(next)
|
||||||
|
mode := s.AmbientMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = settings.AmbientNotify
|
||||||
|
}
|
||||||
|
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
srv.SetSettings(settingsPath, cfg, applyFn)
|
||||||
|
|
||||||
|
// Mirror runtime status to ~/.keel_status for a window-manager bar.
|
||||||
|
// A misconfigured home dir degrades to no status file, not a failed start.
|
||||||
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
|
log.Printf("status file disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
|
||||||
|
h.AddOnChange(writer.Wake)
|
||||||
|
sentinel.AddOnChange(writer.Wake)
|
||||||
|
go writer.Run(context.Background())
|
||||||
|
log.Printf("status: writing %s", statusPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed the window sensor stream into BOTH the harness (for the active mode)
|
||||||
|
// and the ambient sentinel (always-on). On a headless box the source is a
|
||||||
|
// no-op, so both degrade gracefully without new X11 requirements.
|
||||||
|
src := evidence.NewSource()
|
||||||
|
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
|
||||||
|
h.RecordWindow(w)
|
||||||
|
sentinel.OnWindow(w)
|
||||||
|
})
|
||||||
|
go sentinel.Run(context.Background())
|
||||||
|
|
||||||
|
go openBrowser("http://" + addr)
|
||||||
|
|
||||||
|
log.Printf("keeld listening on http://%s", addr)
|
||||||
|
if err := srv.Router().Run(addr); err != nil {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser best-effort launches the default browser after a short delay so
|
||||||
|
// the server is listening first. Failures are logged, not fatal.
|
||||||
|
func openBrowser(url string) {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
var cmd string
|
||||||
|
var args []string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
cmd, args = "xdg-open", []string{url}
|
||||||
|
case "darwin":
|
||||||
|
cmd, args = "open", []string{url}
|
||||||
|
case "windows":
|
||||||
|
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
||||||
|
default:
|
||||||
|
log.Printf("open browser manually: %s", url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := exec.Command(cmd, args...).Start(); err != nil {
|
||||||
|
log.Printf("could not open browser (%v); visit %s", err, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
---
|
||||||
|
title: Keel — Architecture
|
||||||
|
name: keel
|
||||||
|
category: architecture
|
||||||
|
created: 2026-06-04
|
||||||
|
updated: 2026-06-04
|
||||||
|
tags:
|
||||||
|
- keel
|
||||||
|
- human-harness
|
||||||
|
- architecture
|
||||||
|
- antidrift
|
||||||
|
- activitywatch
|
||||||
|
status: authoritative
|
||||||
|
---
|
||||||
|
|
||||||
|
# Keel — Architecture
|
||||||
|
|
||||||
|
> **Name & home.** *Keel* is the system: a human-harness that helps Felix hold
|
||||||
|
> course toward his higher goals. The name answers AntiDrift's metaphor — *drift*
|
||||||
|
> is what a vessel does with nothing to hold it; the *keel* resists drift and
|
||||||
|
> keeps you on an even keel (steady mind). **This repo is `keel`** — AntiDrift's
|
||||||
|
> focus harness becomes Keel's first *mode*. The rename is **complete**: directory,
|
||||||
|
> git repo, Go module (`keel`), binary (`keeld`), runtime (`~/.keel/`), and env
|
||||||
|
> (`KEEL_*`) are all `keel`. The old `~/.antidrift/` ledger needs a one-time manual
|
||||||
|
> move to `~/.keel/` for existing installs.
|
||||||
|
> Moved from `~/dev/higher/` on 2026-06-04 — that directory was the scaffold where
|
||||||
|
> the decision to extend AntiDrift was made, and now retires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What we're building (one sentence)
|
||||||
|
|
||||||
|
**A harness** — a framework that pulls Felix's real state out of his existing
|
||||||
|
tools, hands it to a swappable brain, and turns the reply into actions and two
|
||||||
|
surfaces. The brain, the storage, and the data sources are all **reused**; the
|
||||||
|
harness is the only thing we build. The target is one system (Keel — the single
|
||||||
|
screen), grown by **loosening AntiDrift's controller**, not by starting over.
|
||||||
|
|
||||||
|
The inversion holds: this is a *human-harness*. In an agent harness a model is
|
||||||
|
the observed worker; here Felix is the observed subject and the model is the
|
||||||
|
operator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core decisions (locked 2026-06-04)
|
||||||
|
|
||||||
|
1. **The product is the harness.** The brain (`claude` / `codex` / **Hermes**) is
|
||||||
|
rented, interchangeable compute reached over a CLI or the network — *not* the
|
||||||
|
point. The value is the framework that feeds information *in* and acts on what
|
||||||
|
comes *back*. Hermes is real (an agent harness on another machine); it is one
|
||||||
|
brain option, not a dependency.
|
||||||
|
|
||||||
|
2. **Reuse AntiDrift's engine; loosen its controller.** AntiDrift already is this
|
||||||
|
harness for one plane. Its controller is welded to *commitment = next_action +
|
||||||
|
success_condition + timebox*. We loosen that into a general **collect → brain →
|
||||||
|
act** loop where a focus-session is just **one mode**. The harness now hosts a
|
||||||
|
second, **off-screen** mode; further modes (body, review, capture) are
|
||||||
|
aspirational. The core architecture is salvageable — we widen scope, we don't
|
||||||
|
rebuild.
|
||||||
|
- Worked example (the shipped non-focus mode): from the phone web UI, *"what
|
||||||
|
worthwhile off-screen thing should I do now?"* → the harness reads today's
|
||||||
|
Marvin tasks + the `~/owc` goals and life-domain `bug-*.md`, the brain
|
||||||
|
proposes one off-screen action, the UI asks Felix for feedback, and on
|
||||||
|
approval the Marvin effector files the task.
|
||||||
|
|
||||||
|
3. **Storage = ActivityWatch.** AW is already a general append-only event store
|
||||||
|
with a REST API, and it is currently the single richest *unused* asset
|
||||||
|
(~1 yr / ~287k events). Keel gets its **own AW bucket(s)** for memory. **No new
|
||||||
|
database, no new event store.** AW is therefore both a *sensor* (screen-time
|
||||||
|
ground truth) and the *store* (Keel's memory).
|
||||||
|
|
||||||
|
4. **Two surfaces, both — no fork.**
|
||||||
|
- **Web UI** — configure, interact, discuss; phone-friendly. The place Felix
|
||||||
|
*talks to* Keel.
|
||||||
|
- **WM status bar** — reflect current status (AntiDrift already writes
|
||||||
|
`~/.keel_status`). The reminder sentence lives here; it is one *status
|
||||||
|
element*, **not** a deliverable. (No "nice summaries" as a product.)
|
||||||
|
|
||||||
|
5. **Life-information root = `~/owc`** (owncloud markdown), formerly called
|
||||||
|
"clawd". Values, goals, the 22 `resources/bug-*.md`, weekly reviews,
|
||||||
|
self-management frameworks, capture stream. **Reference, never duplicate.**
|
||||||
|
|
||||||
|
6. **Effectors: start gated, trend toward autonomy.** Day one: read + *propose,
|
||||||
|
Felix confirms in the web UI*. Target: the brain does a lot on its own —
|
||||||
|
**update Beeminder datapoints, update Marvin to-dos, update `~/owc` notes.**
|
||||||
|
The gate (propose-and-confirm vs. just-do-it) is a **config surface per
|
||||||
|
effector**, not a vibe — this is the agency-vs-cage line and it stays Felix's
|
||||||
|
to set.
|
||||||
|
|
||||||
|
7. **Still in force:** don't build a new event store; AntiDrift is the substrate;
|
||||||
|
reference source-of-truth; local-first; every session ends with visible proof;
|
||||||
|
the status line stays small.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The harness (the components we build)
|
||||||
|
|
||||||
|
```
|
||||||
|
SOURCES (read) KEEL (the framework = the product) BRAIN (swappable)
|
||||||
|
───────────── ────────────────────────────────────── ──────────────────
|
||||||
|
AW :5600 ───────┐ ┌──────────────────────────────────────┐ claude (local CLI)
|
||||||
|
AntiDrift ──────┤ feed │ ① COLLECTORS → ③ ASSEMBLER → [BRAIN] │ send ──▶ codex (local CLI)
|
||||||
|
HQ hq.db ───────┤ ───────▶ │ ▲ │ │ Hermes (remote API)
|
||||||
|
Consume :8000 ──┤ │ │ ▼ │ ◀─ reply
|
||||||
|
daily.db :2200 ─┤ │ ② MEMORY = AW buckets ④ EFFECTORS │ (just compute;
|
||||||
|
Beeminder ──────┤ │ (reuse AW; no new DB) │ │ interchangeable)
|
||||||
|
Marvin ammcp ───┘ └────────────────────────────────┼─────┘
|
||||||
|
~/owc (frame) ──── grounds the brief ────────────────────────┤
|
||||||
|
⑤ renders to ──┴──────────┐
|
||||||
|
▼ ▼
|
||||||
|
WEB UI WM STATUS BAR
|
||||||
|
(configure/interact/discuss) (current status)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each component has one job, a defined interface, and can be built/tested alone:
|
||||||
|
|
||||||
|
- **① Collectors** — one small read-only adapter per source. Contract:
|
||||||
|
`read(window) -> Signal[]`. Today's real interfaces: AW `POST :5600/api/0/query/`;
|
||||||
|
AntiDrift `tail ~/.keel/audit.jsonl` (+ live SSE `:7777/events`); HQ
|
||||||
|
read-only `hq.db` (+ `:8765`); Consume `GET :8000/api/...`; daily read-only
|
||||||
|
`daily.db` (+ `:2200`); Beeminder `beeline`; Marvin `ammcp` MCP / `am --json`.
|
||||||
|
Depends only on the source being up; degrades to a dropped signal if not.
|
||||||
|
|
||||||
|
- **② Memory** — AW buckets owned by Keel. Keel writes its own derived events here
|
||||||
|
(mode_started, proposal_made, action_taken, spiral_flagged, …) so it *remembers
|
||||||
|
across runs* and can see trends a single tool can't. Rebuildable, queryable via
|
||||||
|
AW's REST API. Depends on AW only. (See §7.)
|
||||||
|
|
||||||
|
- **③ Context assembler** — builds the brief handed to the brain = relevant
|
||||||
|
collector signals + memory (recent + trend) + the `~/owc` frame (the value/goal
|
||||||
|
at stake, the relevant `bug-*.md`). Budgets it. Depends on ① ② and `~/owc`.
|
||||||
|
Privacy boundary lives here: titles that can leak secrets are filtered before
|
||||||
|
they reach the brain.
|
||||||
|
|
||||||
|
- **Brain adapter** — pluggable. Local: shell `claude --print` / `codex exec`
|
||||||
|
(exactly what AntiDrift's `ai.Backend` already does). Remote: call Hermes over
|
||||||
|
the network. Interface: `decide(brief) -> {status, proposals[]}`. Keel treats it
|
||||||
|
as a black box; swapping it changes nothing else.
|
||||||
|
|
||||||
|
- **④ Effectors** — one gated adapter per write target. Today's safe writes:
|
||||||
|
Marvin task create/complete (`am` / `ammcp`, **preserve the `fieldUpdates`
|
||||||
|
CRDT** on write), capture append (one file), AntiDrift start-session / enforce.
|
||||||
|
Roadmap writes: Beeminder datapoint (`beeline`), `~/owc` note edits. Each
|
||||||
|
effector reads its gate from config (propose-and-confirm | auto).
|
||||||
|
|
||||||
|
- **⑤ Surfaces** — render Keel's state. Web UI (rich, phone-friendly, the
|
||||||
|
interaction/approval surface) and the WM status bar (one live line). Both read
|
||||||
|
the same Keel state; neither owns data.
|
||||||
|
|
||||||
|
- **Control loop / scheduler** — drives the cycle: when to collect, when to think,
|
||||||
|
when to surface, when to act. Cheap triggers (a switch-count spike, a Beeminder
|
||||||
|
losedate approaching, a manual web-UI request) decide when to spend a brain
|
||||||
|
call, instead of polling the LLM blindly. This is AntiDrift's state machine,
|
||||||
|
generalized past the single session.
|
||||||
|
|
||||||
|
- **Policy / gates** — per-effector authority + the value frame. Felix's dial
|
||||||
|
from "read-only" to "act freely". Configured in the web UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AntiDrift is the seed, not a peer
|
||||||
|
|
||||||
|
Keel is AntiDrift's port set, widened. The parts already exist:
|
||||||
|
|
||||||
|
| Keel part | AntiDrift today | Keel (generalized) |
|
||||||
|
|---|---|---|
|
||||||
|
| Collectors | `evidence.Source` (X11), `tasks.Provider` (Marvin) | + AW, HQ, Consume, daily, Beeminder, `~/owc` |
|
||||||
|
| Frame | `knowledge.Source` → one file | `~/owc`: values, goals, 22 `bug-*.md`, frameworks |
|
||||||
|
| Brain | `ai.Backend` → local `claude`/`codex` | + Hermes (remote); pluggable interface |
|
||||||
|
| Memory | ephemeral (dies in `state.json`) | **AW buckets** (durable, queryable) |
|
||||||
|
| Effectors | `enforce.Guard` (window-minimize) | + Marvin, capture, Beeminder, `~/owc` notes |
|
||||||
|
| Surfaces | web UI `:7777` + `~/.keel_status` | richer web UI (phone) + status bar |
|
||||||
|
| Control loop | session state machine (locked/planning/active/review) | **modes** (focus is one); a session is one mode |
|
||||||
|
|
||||||
|
**Loosening the controller** concretely means: generalize `Commitment` from a
|
||||||
|
work-session into a *mode invocation* (a mode has its own collectors, frame slice,
|
||||||
|
brain prompt, and allowed effectors); the focus-session remains the
|
||||||
|
`next_action/success_condition/timebox` mode; the off-screen mode reads the
|
||||||
|
life-domain bugs + goals and proposes one off-screen action; a future "capture"
|
||||||
|
mode would just route a thought. The runtime state machine becomes the loop in §3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. One loop, concrete
|
||||||
|
|
||||||
|
Off-screen mode, from the phone, end to end — **this is what ships today**:
|
||||||
|
1. **Collect** — today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md`
|
||||||
|
goals + the life-domain `~/owc/resources/bug-*.md` notes — plus recent
|
||||||
|
proposals and their outcomes recalled from the `keel.events` AW bucket —
|
||||||
|
assembled into a single budgeted brief.
|
||||||
|
2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen
|
||||||
|
action plus a short rationale.
|
||||||
|
3. **Surface** — the phone-first web UI renders a proposal card and asks Felix to
|
||||||
|
confirm or dismiss.
|
||||||
|
4. **Act** — on confirm, the Marvin `Create` effector files the action as a task
|
||||||
|
(CRDT-preserving `am add`); the mode is one-shot and the harness returns idle.
|
||||||
|
|
||||||
|
No new store touched. The brain was rented for one call. Everything else was Keel.
|
||||||
|
|
||||||
|
**Shipped:** memory. Off-screen now writes its decisions to the `keel.events` AW
|
||||||
|
bucket (`proposal_made` / `action_taken` / `proposal_dismissed`, correlated by a
|
||||||
|
random `proposal_id`) and reads its recent history back into the brief — so a
|
||||||
|
proposal can avoid repeating itself and follow up on what was dismissed or left
|
||||||
|
undone. Storage sits behind a thin `memory.Store` port (AW-backed, or a nop when
|
||||||
|
AW is down), the seam every later mode reuses; see §7. The `keel.state` bucket and
|
||||||
|
the trend/spiral detection that reads this data remain future increments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sources & targets (the real ecosystem)
|
||||||
|
|
||||||
|
| Tool | Read | Write | Role | Source of truth |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ActivityWatch | `POST :5600/api/0/query/`, sqlite | new buckets via REST | sensor **+ store** | `peewee-sqlite.v2.db` |
|
||||||
|
| AntiDrift | `audit.jsonl`, SSE `:7777` | `POST :7777` cmds, enforce | focus mode + enforcement | `~/.keel/` |
|
||||||
|
| HQ | `hq.db` r/o, `:8765` | `dashboard.write_artifact` | agent-execution evidence | `hq.db` (~23k events) |
|
||||||
|
| Consume | `GET :8000/api` | (later) | intake/reflection signal | item frontmatter |
|
||||||
|
| daily | `daily.db` r/o, `:2200` | — | mood/habit checkout | `daily.db` |
|
||||||
|
| Beeminder | `beeline` | `beeline` datapoints | committed-evidence rollups | Beeminder API |
|
||||||
|
| Marvin | `ammcp` / `am --json` | `am` / `ammcp` (keep CRDT) | intent / to-dos / bugs | CouchDB |
|
||||||
|
| `~/owc` | markdown globs, `.zk` | note edits (later) | values/goals/bugs frame | `~/owc` files |
|
||||||
|
| Brain | — | — | rented compute | claude/codex/Hermes |
|
||||||
|
|
||||||
|
Known frictions Keel inherits (from the survey): beesync has **no scheduler**
|
||||||
|
(Beeminder data lags; `[daily]` bridge commented out); the life-bug concept is
|
||||||
|
**forked** (22 rich `~/owc` markdown bugs vs. 5 thin Marvin tasks); **capture is
|
||||||
|
broken** (`blurt` → clipboard, dead `inbox.md`, two rival `stream.md`); AW's year
|
||||||
|
of data is read by nothing today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Memory in ActivityWatch (the storage design)
|
||||||
|
|
||||||
|
AW is a typed, append-only, timestamped event store with buckets + a REST/query
|
||||||
|
API — i.e. it is already the event store the §0 correction said not to rebuild.
|
||||||
|
Keel gets dedicated buckets (e.g. `keel.state`, `keel.events`) and writes
|
||||||
|
**derived** events: `mode_started`, `proposal_made`, `feedback_given`,
|
||||||
|
`action_taken`, `spiral_flagged`, `coach_line`. Properties:
|
||||||
|
- **Reference, don't duplicate** — these hold pointers (a Marvin id, a `bug-*.md`
|
||||||
|
path, an audit `session_id`), not copies of other tools' truth.
|
||||||
|
- **Rebuildable** — derivable from the underlying ledgers; AW is a cache/index of
|
||||||
|
Keel's own decisions plus a join surface over the sources.
|
||||||
|
- **Queryable over time** — this is what unlocks trend/spiral detection ("caffeine
|
||||||
|
bug + mood dip + rising switches, same shape as three weeks ago") that no single
|
||||||
|
tool can do, because each tool only answers "what now".
|
||||||
|
|
||||||
|
Open sub-decision: whether work-laptop AW data is peer-synced in (cross-device
|
||||||
|
behavioral story) or left out initially.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open questions (not yet decided)
|
||||||
|
|
||||||
|
- **Effector gate policy** — exact default authority per write target on day one,
|
||||||
|
and how Felix raises/lowers it from the web UI.
|
||||||
|
- **Capture unification** — which single file is canonical (resolve
|
||||||
|
`~/owc/stream.md` vs `~/wrk/pkb/stream.md` vs dead inboxes) and rewiring `blurt`
|
||||||
|
to append there.
|
||||||
|
- **Cross-device AW** — sync work-laptop AW or not (see §7).
|
||||||
|
- **Secret hygiene** — an increasingly autonomous operator runs as the full Unix
|
||||||
|
user; plaintext keys exist on disk; the privacy boundary in ③ must be real.
|
||||||
|
|
||||||
|
### Resolved
|
||||||
|
|
||||||
|
- **Code rename** — complete as of Phase 0 of the controller refactor. Go module
|
||||||
|
is `keel`, binary is `keeld`, runtime is `~/.keel/`, env prefix is `KEEL_*`.
|
||||||
|
Existing installs with a live ledger under `~/.antidrift/` need a one-time
|
||||||
|
manual move to `~/.keel/`.
|
||||||
|
- **Controller refactor shape** — resolved: grown in place, not branched. The
|
||||||
|
focus-only `session.Controller` is now a generic `harness.Harness` that hosts
|
||||||
|
one `mode.Mode` at a time (the `collect → brain → act` loop of §3); focus is the
|
||||||
|
first mode and off-screen the second. Per-mode persistence lives under
|
||||||
|
`~/.keel/modes/<kind>/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What this supersedes (from `~/dev/higher/`)
|
||||||
|
|
||||||
|
- The whole `~/dev/higher/` planning directory: it was the scaffold for deciding
|
||||||
|
to extend AntiDrift. This doc is now the authoritative home; that dir retires to
|
||||||
|
a pointer.
|
||||||
|
- "clawd" → **`~/owc`** everywhere.
|
||||||
|
- The plan's premise that the operator "already knows Felix via `~/.hermes`" and
|
||||||
|
the survey finding "Hermes doesn't exist" are both resolved: **Hermes is real
|
||||||
|
but remote, and the brain is pluggable** — grounding comes from the `~/owc`
|
||||||
|
frame regardless of which brain runs.
|
||||||
|
- The plan's §6 recommendation ("a thin *reader* layer is the real architecture")
|
||||||
|
is **superseded**: the reader is merely the **collectors layer** of Keel. The
|
||||||
|
target is the full system (one screen, two surfaces, durable memory,
|
||||||
|
gated→autonomous effectors), grown from AntiDrift.
|
||||||
|
- "Don't build an event store" is **sharpened**: storage = **ActivityWatch
|
||||||
|
buckets**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Smallest real slice (a vertical, not a summary)
|
||||||
|
|
||||||
|
To stay honest to "ship visible proof" without boiling the ocean, the first slice
|
||||||
|
is **off-screen mode, end to end** (§5) on the existing AntiDrift web surface —
|
||||||
|
collectors (`~/owc` goals + life-domain bugs + Marvin), the brain (`ai.Proposer`),
|
||||||
|
the phone-first web UI proposal card, and the Marvin `Create` effector behind a
|
||||||
|
confirm gate. It exercises every Keel component once except memory, on the
|
||||||
|
smallest mode, and produces a real filed task — interaction and action, not a
|
||||||
|
coaching sentence. **This slice now ships;** durable AW memory (§7) is the next
|
||||||
|
increment that closes the loop.
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
# Windows 11 Support Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make AntiDrift function end-to-end on Windows 11 by adding `//go:build windows` adapters behind the existing `evidence.Source` and `enforce.Guard` ports — active-window sensing (polling) and window-minimize enforcement — with no consumer code changes.
|
||||||
|
|
||||||
|
**Architecture:** Two new windows-tagged adapters call a small shared Win32 binding (`internal/winapi`). The syscall-free logic (process-path → class normalization, emit-on-change tracking) is extracted into untagged files so it is unit-tested on Linux via TDD. The syscall-bound code is verified by cross-compilation only (no Windows machine is available). The current `//go:build !linux` no-op fallbacks are narrowed to `//go:build !linux && !windows` so Windows links the real adapters while macOS/other stay no-ops.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.26, `golang.org/x/sys/windows` (already an indirect dep; promoted to direct), pure Go (no cgo). Win32 calls: `GetForegroundWindow`, `GetWindowThreadProcessId`, `OpenProcess`/`QueryFullProcessImageName`/`CloseHandle` (all typed in `x/sys/windows`), plus `GetWindowTextW` and `ShowWindow` via `windows.NewLazySystemDLL("user32.dll")`.
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-06-02-windows-support-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
- Create `internal/winapi/class.go` — untagged — `ClassFromImagePath` (pure: image path → class string). Builds on all platforms; this is the only file that makes package `winapi` compile on Linux.
|
||||||
|
- Create `internal/winapi/class_test.go` — untagged — table test for `ClassFromImagePath` (runs on Linux).
|
||||||
|
- Create `internal/winapi/winapi.go` — `//go:build windows` — `ForegroundWindow()` and `MinimizeForeground()` over the Win32 calls.
|
||||||
|
- Create `internal/evidence/foreground_tracker.go` — untagged — `foregroundTracker.changed` (pure emit-on-change predicate). Runs on Linux.
|
||||||
|
- Create `internal/evidence/foreground_tracker_test.go` — untagged — tests for the tracker.
|
||||||
|
- Create `internal/evidence/windows.go` — `//go:build windows` — polling `Source` (`NewSource`, `windowsSource.Watch`).
|
||||||
|
- Create `internal/enforce/windows.go` — `//go:build windows` — `Guard` (`NewGuard`, `windowsGuard.MinimizeActive`).
|
||||||
|
- Modify `internal/evidence/source_other.go:1` — build tag `//go:build !linux` → `//go:build !linux && !windows`.
|
||||||
|
- Modify `internal/enforce/guard_other.go:1` — build tag `//go:build !linux` → `//go:build !linux && !windows`.
|
||||||
|
- Modify `go.mod` — `go mod tidy` promotes `golang.org/x/sys` from indirect to direct.
|
||||||
|
|
||||||
|
No changes to `session`, `web`, `domain`, or `cmd` — they already consume the ports and degrade on `Health`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Pure class-name normalization (TDD on Linux)
|
||||||
|
|
||||||
|
`ClassFromImagePath` turns a Windows process image path into the on-task class
|
||||||
|
identity: base file name, trailing `.exe` removed (case-insensitive),
|
||||||
|
lowercased. It must be correct when the test runs on Linux, so it parses
|
||||||
|
separators itself rather than using OS-dependent `path/filepath`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/winapi/class.go`
|
||||||
|
- Test: `internal/winapi/class_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `internal/winapi/class_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClassFromImagePath(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{`C:\Program Files\Microsoft VS Code\Code.exe`, "code"},
|
||||||
|
{`C:\Windows\explorer.exe`, "explorer"},
|
||||||
|
{`chrome.exe`, "chrome"},
|
||||||
|
{`C:\x\FOO.EXE`, "foo"},
|
||||||
|
{`C:\x\My.App.exe`, "my.app"},
|
||||||
|
{`C:/forward/slash/Code.exe`, "code"},
|
||||||
|
{`firefox`, "firefox"},
|
||||||
|
{``, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ClassFromImagePath(c.in); got != c.want {
|
||||||
|
t.Errorf("ClassFromImagePath(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `go test ./internal/winapi/`
|
||||||
|
Expected: FAIL — build error, `undefined: ClassFromImagePath`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `internal/winapi/class.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package winapi is the Windows Win32 binding layer for AntiDrift'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
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ClassFromImagePath derives the on-task class identity from a process image
|
||||||
|
// path: the base file name with any trailing ".exe" removed (case-insensitive),
|
||||||
|
// lowercased. It is the Windows analog of an X11 WM_CLASS. It parses both `\`
|
||||||
|
// and `/` separators so it is correct regardless of the host OS running the
|
||||||
|
// test. Returns "" for an empty path.
|
||||||
|
func ClassFromImagePath(p string) string {
|
||||||
|
if i := strings.LastIndexAny(p, `\/`); i >= 0 {
|
||||||
|
p = p[i+1:]
|
||||||
|
}
|
||||||
|
if len(p) >= 4 && strings.EqualFold(p[len(p)-4:], ".exe") {
|
||||||
|
p = p[:len(p)-4]
|
||||||
|
}
|
||||||
|
return strings.ToLower(p)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `go test ./internal/winapi/`
|
||||||
|
Expected: PASS (`ok antidrift/internal/winapi`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/winapi/class.go internal/winapi/class_test.go
|
||||||
|
git commit -m "Add pure process-path to class normalization for Windows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Emit-on-change tracker (TDD on Linux)
|
||||||
|
|
||||||
|
The X11 source is event-driven; the Windows source polls. `foregroundTracker`
|
||||||
|
reproduces "emit only on change" for the poll loop: it remembers the last
|
||||||
|
observation and reports whether the new one differs. It tracks availability too,
|
||||||
|
so a steady "no foreground window" run does not re-emit every tick.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/evidence/foreground_tracker.go`
|
||||||
|
- Test: `internal/evidence/foreground_tracker_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `internal/evidence/foreground_tracker_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestForegroundTrackerChanged(t *testing.T) {
|
||||||
|
var tr foregroundTracker
|
||||||
|
|
||||||
|
if !tr.changed(true, 100, "A") {
|
||||||
|
t.Fatal("first observation should always report changed")
|
||||||
|
}
|
||||||
|
if tr.changed(true, 100, "A") {
|
||||||
|
t.Error("identical observation should not report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(true, 100, "B") {
|
||||||
|
t.Error("title change on same hwnd should report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(true, 200, "B") {
|
||||||
|
t.Error("hwnd change should report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(false, 0, "") {
|
||||||
|
t.Error("transition to unavailable should report changed")
|
||||||
|
}
|
||||||
|
if tr.changed(false, 0, "") {
|
||||||
|
t.Error("repeated unavailable should not report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(true, 200, "B") {
|
||||||
|
t.Error("transition back to available should report changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `go test ./internal/evidence/ -run TestForegroundTracker`
|
||||||
|
Expected: FAIL — build error, `undefined: foregroundTracker`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `internal/evidence/foreground_tracker.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
// foregroundTracker remembers the last observed foreground window so the
|
||||||
|
// Windows polling Source emits only on change. hwnd is held as uintptr so this
|
||||||
|
// file stays platform-neutral (it must build and test on Linux).
|
||||||
|
type foregroundTracker struct {
|
||||||
|
primed bool
|
||||||
|
available bool
|
||||||
|
hwnd uintptr
|
||||||
|
title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// changed reports whether (available, hwnd, title) differs from the last
|
||||||
|
// observation and records the new values. The first call always returns true.
|
||||||
|
func (t *foregroundTracker) changed(available bool, hwnd uintptr, title string) bool {
|
||||||
|
if t.primed && available == t.available && hwnd == t.hwnd && title == t.title {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.primed, t.available, t.hwnd, t.title = true, available, hwnd, title
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `go test ./internal/evidence/ -run TestForegroundTracker`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/evidence/foreground_tracker.go internal/evidence/foreground_tracker_test.go
|
||||||
|
git commit -m "Add emit-on-change tracker for the Windows polling sensor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Win32 binding layer (cross-compile verified)
|
||||||
|
|
||||||
|
The syscall surface. Cannot be unit-tested without Windows; verified by
|
||||||
|
cross-compilation. All referenced `x/sys/windows` symbols were confirmed present
|
||||||
|
via `GOOS=windows go doc` while writing the spec.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/winapi/winapi.go`
|
||||||
|
- Modify: `go.mod` (via `go mod tidy`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the implementation**
|
||||||
|
|
||||||
|
Create `internal/winapi/winapi.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
procGetWindowTextW = user32.NewProc("GetWindowTextW")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForegroundWindow returns the current foreground window's handle (as a uintptr
|
||||||
|
// so platform-neutral callers need not import windows), its title, and its
|
||||||
|
// on-task class (process exe base name; see ClassFromImagePath). ok is false
|
||||||
|
// when there is no foreground window (e.g. secure desktop / lock screen).
|
||||||
|
func ForegroundWindow() (hwnd uintptr, title, class string, ok bool) {
|
||||||
|
h := windows.GetForegroundWindow()
|
||||||
|
if h == 0 {
|
||||||
|
return 0, "", "", false
|
||||||
|
}
|
||||||
|
return uintptr(h), windowTitle(h), windowClass(h), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowTitle(h windows.HWND) string {
|
||||||
|
const max = 512
|
||||||
|
buf := make([]uint16, max)
|
||||||
|
n, _, _ := procGetWindowTextW.Call(
|
||||||
|
uintptr(h),
|
||||||
|
uintptr(unsafe.Pointer(&buf[0])),
|
||||||
|
uintptr(max),
|
||||||
|
)
|
||||||
|
return windows.UTF16ToString(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowClass(h windows.HWND) string {
|
||||||
|
var pid uint32
|
||||||
|
if _, err := windows.GetWindowThreadProcessId(h, &pid); err != nil || pid == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(proc)
|
||||||
|
|
||||||
|
buf := make([]uint16, windows.MAX_PATH)
|
||||||
|
size := uint32(len(buf))
|
||||||
|
if err := windows.QueryFullProcessImageName(proc, 0, &buf[0], &size); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ClassFromImagePath(windows.UTF16ToString(buf[:size]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinimizeForeground minimizes the current foreground window. It returns nil
|
||||||
|
// when nothing is focused. ShowWindow's BOOL return reports prior visibility,
|
||||||
|
// not success, so there is nothing to error-check; minimize is best-effort by
|
||||||
|
// the Guard contract.
|
||||||
|
func MinimizeForeground() error {
|
||||||
|
h := windows.GetForegroundWindow()
|
||||||
|
if h == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
procShowWindow.Call(uintptr(h), uintptr(windows.SW_MINIMIZE))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Promote the dependency and verify the package cross-compiles**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
GOOS=windows GOARCH=amd64 go build ./internal/winapi/
|
||||||
|
```
|
||||||
|
Expected: both succeed with no output. `go.mod` now lists `golang.org/x/sys` in the direct (non-`// indirect`) require block.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the package still builds on Linux (pure file only)**
|
||||||
|
|
||||||
|
Run: `go test ./internal/winapi/`
|
||||||
|
Expected: PASS — on Linux the package is just `class.go` + its test; `winapi.go` is excluded by its build tag.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/winapi/winapi.go go.mod go.sum
|
||||||
|
git commit -m "Add Win32 binding for foreground window and minimize"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Windows evidence.Source adapter (cross-compile verified)
|
||||||
|
|
||||||
|
The polling sensor. Emits one snapshot immediately, then on every 750ms tick
|
||||||
|
emits only when the foreground window or title changed (via `foregroundTracker`
|
||||||
|
from Task 2 and `winapi.ForegroundWindow` from Task 3). Also narrows the
|
||||||
|
non-Linux fallback so it no longer claims Windows.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/evidence/windows.go`
|
||||||
|
- Modify: `internal/evidence/source_other.go:1`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Narrow the fallback build tag**
|
||||||
|
|
||||||
|
In `internal/evidence/source_other.go`, change the first line:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build !linux && !windows
|
||||||
|
```
|
||||||
|
|
||||||
|
(from `//go:build !linux`). Leave the rest of the file unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the Windows source**
|
||||||
|
|
||||||
|
Create `internal/evidence/windows.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/winapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pollInterval is how often the Windows sensor samples the foreground window.
|
||||||
|
// ~1s latency on a window switch is immaterial for a focus tracker, and polling
|
||||||
|
// avoids the message-loop/callback machinery a SetWinEventHook source needs.
|
||||||
|
const pollInterval = 750 * time.Millisecond
|
||||||
|
|
||||||
|
// NewSource returns the Windows active-window sensor (polling).
|
||||||
|
func NewSource() Source { return windowsSource{} }
|
||||||
|
|
||||||
|
type windowsSource struct{}
|
||||||
|
|
||||||
|
// Watch emits the current window immediately, then samples every pollInterval,
|
||||||
|
// emitting only when the foreground window or its title changes. A read with no
|
||||||
|
// foreground window yields an Unavailable snapshot (once, until it recovers). It
|
||||||
|
// runs until ctx is cancelled. It never panics the daemon.
|
||||||
|
func (windowsSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||||
|
var tr foregroundTracker
|
||||||
|
poll := func() {
|
||||||
|
hwnd, title, class, ok := winapi.ForegroundWindow()
|
||||||
|
if !tr.changed(ok, hwnd, title) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no foreground window"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(WindowSnapshot{Title: title, Class: class, Health: EvidenceHealth{Available: true}})
|
||||||
|
}
|
||||||
|
|
||||||
|
poll() // immediate current window
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify Windows build and Linux tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
GOOS=windows GOARCH=amd64 go build ./internal/evidence/
|
||||||
|
go test ./internal/evidence/
|
||||||
|
```
|
||||||
|
Expected: the Windows build succeeds with no output; the Linux test run passes (tracker test included, X11/other files unaffected).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the fallback world is intact (macOS still no-op)**
|
||||||
|
|
||||||
|
Run: `GOOS=darwin GOARCH=amd64 go build ./internal/evidence/`
|
||||||
|
Expected: succeeds — `source_other.go` (`!linux && !windows`) still provides `NewSource` on darwin.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/evidence/windows.go internal/evidence/source_other.go
|
||||||
|
git commit -m "Add Windows polling active-window sensor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Windows enforce.Guard adapter (cross-compile verified)
|
||||||
|
|
||||||
|
The minimize guard. Thin wrapper over `winapi.MinimizeForeground`. Narrows the
|
||||||
|
non-Linux fallback the same way as Task 4.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/enforce/windows.go`
|
||||||
|
- Modify: `internal/enforce/guard_other.go:1`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Narrow the fallback build tag**
|
||||||
|
|
||||||
|
In `internal/enforce/guard_other.go`, change the first line:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build !linux && !windows
|
||||||
|
```
|
||||||
|
|
||||||
|
(from `//go:build !linux`). Leave the rest of the file unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the Windows guard**
|
||||||
|
|
||||||
|
Create `internal/enforce/windows.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"antidrift/internal/winapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewGuard returns the Windows window-minimize guard.
|
||||||
|
func NewGuard() Guard { return windowsGuard{} }
|
||||||
|
|
||||||
|
type windowsGuard struct{}
|
||||||
|
|
||||||
|
// MinimizeActive minimizes the current foreground window. It is best-effort:
|
||||||
|
// with nothing focused it does nothing and returns nil. Stateless and per-call,
|
||||||
|
// mirroring the short-lived X11 connection model.
|
||||||
|
func (windowsGuard) MinimizeActive(context.Context) error {
|
||||||
|
return winapi.MinimizeForeground()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify Windows build and the fallback world**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
GOOS=windows GOARCH=amd64 go build ./internal/enforce/
|
||||||
|
GOOS=darwin GOARCH=amd64 go build ./internal/enforce/
|
||||||
|
go build ./internal/enforce/
|
||||||
|
```
|
||||||
|
Expected: all three succeed with no output (Windows uses `windows.go`; darwin and Linux-host build paths still resolve `NewGuard`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/enforce/windows.go internal/enforce/guard_other.go
|
||||||
|
git commit -m "Add Windows window-minimize guard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Full cross-compilation gate and regression check
|
||||||
|
|
||||||
|
The primary automated guarantee for the syscall-bound code: the whole module
|
||||||
|
cross-compiles for Windows, the fallback world is intact for macOS, and the
|
||||||
|
Linux host build and full test suite are still green.
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Whole-module Windows cross-compile**
|
||||||
|
|
||||||
|
Run: `GOOS=windows GOARCH=amd64 go build ./...`
|
||||||
|
Expected: succeeds with no output. (This is the headline check: `cmd/antidriftd`, `evidence`, `enforce`, `winapi`, and every consumer link for Windows.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Whole-module macOS cross-compile (fallback intact)**
|
||||||
|
|
||||||
|
Run: `GOOS=darwin GOARCH=amd64 go build ./...`
|
||||||
|
Expected: succeeds with no output — proves the `!linux && !windows` tag edits did not orphan `NewSource`/`NewGuard` on other platforms.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Linux host build and full test suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
Expected: build succeeds; all tests pass, including the new `winapi` and `evidence` pure-logic tests, with the existing Linux X11 integration tests unaffected.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm the Windows binary actually links (optional sanity)**
|
||||||
|
|
||||||
|
Run: `GOOS=windows GOARCH=amd64 go build -o /tmp/antidriftd.exe ./cmd/antidriftd && ls -l /tmp/antidriftd.exe`
|
||||||
|
Expected: a `.exe` is produced.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit (if `go mod tidy` left any tidy changes)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "Verify Windows cross-compile and Linux regression for Windows support" --allow-empty
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred manual verification (when a Windows 11 machine is available)
|
||||||
|
|
||||||
|
Not part of this plan's automated gates — recorded for whoever runs it on real
|
||||||
|
hardware later (from the spec):
|
||||||
|
|
||||||
|
- Run `antidriftd`; confirm the browser opens and the live view shows the
|
||||||
|
current window title and class.
|
||||||
|
- Switch windows and change a browser tab; confirm snapshots update and health
|
||||||
|
reads available.
|
||||||
|
- Start a commitment with "Enforce focus" armed; focus an off-task window;
|
||||||
|
confirm the drift judge fires and the window minimizes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- **Spec coverage:** both ports (Task 4 sensor, Task 5 guard); `winapi` binding (Task 3); build-tag narrowing (Tasks 4–5); `go mod tidy` promotion (Task 3); pure-logic TDD for class + change-detection (Tasks 1–2); cross-compile + darwin-fallback + Linux-regression gates (Task 6); deferred manual steps recorded. No fabricated syscall mocks. The `Class` = exe-base-name decision is implemented in Task 1.
|
||||||
|
- **Type consistency:** `ClassFromImagePath` (Tasks 1, 3); `foregroundTracker.changed(available, hwnd, title)` with `hwnd uintptr` (Tasks 2, 4); `winapi.ForegroundWindow() (uintptr, string, string, bool)` and `winapi.MinimizeForeground() error` (Tasks 3, 4, 5). Module path `antidrift` confirmed against `go.mod`.
|
||||||
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,246 @@
|
|||||||
|
# Windows 11 Support — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-02
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Scope:** Full feature parity on Windows 11 — both OS ports (active-window
|
||||||
|
sensing and window-minimize enforcement) — implemented as `//go:build windows`
|
||||||
|
adapters behind the existing interfaces. No consumer code changes.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
AntiDrift's value is making drift visible by watching the active window and, on
|
||||||
|
confirmed off-task drift, minimizing it. Both capabilities are X11-only, gated
|
||||||
|
behind `//go:build linux`. On any non-Linux target the build links the
|
||||||
|
`//go:build !linux` no-op fallbacks, so the daemon starts and serves its UI but
|
||||||
|
the entire focus loop is dead: no evidence, no drift detection, no nudges, no
|
||||||
|
enforcement.
|
||||||
|
|
||||||
|
Today's Windows behavior, verified:
|
||||||
|
|
||||||
|
- `GOOS=windows GOARCH=amd64 go build ./cmd/antidriftd` compiles cleanly (no
|
||||||
|
cgo).
|
||||||
|
- The daemon would launch and open a browser (`cmd/antidriftd/main.go` already
|
||||||
|
handles the `windows` case via `rundll32`).
|
||||||
|
- `evidence.NewSource()` returns a no-op reporting "no active-window sensor on
|
||||||
|
this platform" (`internal/evidence/source_other.go`).
|
||||||
|
- `enforce.NewGuard()` returns a no-op whose `MinimizeActive` does nothing
|
||||||
|
(`internal/enforce/guard_other.go`).
|
||||||
|
|
||||||
|
This design fills both ports on Windows so AntiDrift functions end-to-end.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- A real `evidence.Source` on Windows: emit `WindowSnapshot{Title, Class,
|
||||||
|
Health}` on foreground-window and title changes.
|
||||||
|
- A real `enforce.Guard` on Windows: minimize the foreground window on demand.
|
||||||
|
- Pure Go, no cgo. Cross-compilation from Linux stays clean.
|
||||||
|
- No changes to `session`, `web`, `domain`, or `cmd` — the work lives entirely
|
||||||
|
behind the two existing ports.
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- Event-driven sensing via `SetWinEventHook` (see Approach B, rejected below).
|
||||||
|
The `Source` interface hides the polling-vs-event choice; B can replace A
|
||||||
|
later with zero consumer impact if latency ever matters.
|
||||||
|
- Windows ARM64.
|
||||||
|
- Installer, system tray, packaging, autostart.
|
||||||
|
- Any session-policy or web/UI changes.
|
||||||
|
|
||||||
|
## Constraints that shaped this design
|
||||||
|
|
||||||
|
- **Verification is compile-only for now.** Development is on Manjaro Linux with
|
||||||
|
no Windows 11 machine available. The design therefore minimizes untested
|
||||||
|
syscall surface: no callbacks, no Win32 message loop, no OS-thread affinity.
|
||||||
|
Live runtime verification is deferred (see Testing).
|
||||||
|
|
||||||
|
## Port contracts being satisfied
|
||||||
|
|
||||||
|
From `internal/evidence/evidence.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type WindowSnapshot struct {
|
||||||
|
Title string // full window title
|
||||||
|
Class string // app identity, matched case-folded against allowed classes
|
||||||
|
Health EvidenceHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source interface {
|
||||||
|
// Watch runs until ctx is cancelled, invoking onChange on every
|
||||||
|
// active-window change, and once immediately with the current window.
|
||||||
|
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From `internal/enforce/enforce.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Guard interface {
|
||||||
|
// MinimizeActive minimizes the currently-focused window. Idempotent and
|
||||||
|
// best-effort: returns an error for diagnostics; callers never block on it.
|
||||||
|
MinimizeActive(ctx context.Context) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Class` field is the authoritative on-task signal. `MatchesAllowed`
|
||||||
|
(`internal/evidence/context.go`) compares it **case-folded, exact match**
|
||||||
|
against the session's allowed window classes (or matches a title substring).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All new code is behind `//go:build windows`. No consumer changes — `session`,
|
||||||
|
`web`, and `cmd/antidriftd` already call `evidence.NewSource()` /
|
||||||
|
`enforce.NewGuard()` and degrade on the health flags.
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `internal/winapi/winapi.go` (`//go:build windows`) — a small shared binding
|
||||||
|
layer over the Win32 calls, so the two adapters don't each redeclare the same
|
||||||
|
procs.
|
||||||
|
- `internal/evidence/windows.go` (`//go:build windows`) — the polling `Source`.
|
||||||
|
- `internal/enforce/windows.go` (`//go:build windows`) — the `ShowWindow`
|
||||||
|
`Guard`.
|
||||||
|
|
||||||
|
### Build-tag edit (correctness-critical)
|
||||||
|
|
||||||
|
The fallbacks are currently tagged `//go:build !linux`, which is what compiles
|
||||||
|
on Windows today. Once `windows.go` files exist in those packages, the fallbacks
|
||||||
|
must exclude Windows too, or the build gets duplicate `NewSource`/`NewGuard`
|
||||||
|
symbols:
|
||||||
|
|
||||||
|
- `internal/evidence/source_other.go`: `//go:build !linux` →
|
||||||
|
`//go:build !linux && !windows`
|
||||||
|
- `internal/enforce/guard_other.go`: `//go:build !linux` →
|
||||||
|
`//go:build !linux && !windows`
|
||||||
|
|
||||||
|
This yields three mutually exclusive worlds per port: `linux` (X11), `windows`
|
||||||
|
(new), everything-else (no-op). macOS and any other GOOS remain no-ops, exactly
|
||||||
|
as today.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
`golang.org/x/sys` is already in `go.mod` (indirect, v0.41.0). `go mod tidy`
|
||||||
|
promotes it to a direct require. No new module, no cgo.
|
||||||
|
|
||||||
|
## Component design
|
||||||
|
|
||||||
|
### `internal/winapi` — shared Win32 binding
|
||||||
|
|
||||||
|
Most of the needed calls are already typed wrappers in `x/sys/windows` (verified
|
||||||
|
under `GOOS=windows go doc`), so the hand-written binding is deliberately tiny.
|
||||||
|
|
||||||
|
Provided by `golang.org/x/sys/windows`, used directly — no LazyDLL needed:
|
||||||
|
|
||||||
|
- `GetForegroundWindow() HWND`
|
||||||
|
- `GetWindowThreadProcessId(hwnd HWND, *uint32) (tid uint32, err error)` → owning PID
|
||||||
|
- `OpenProcess`, `QueryFullProcessImageName`, `CloseHandle` → process image path
|
||||||
|
- the `windows.HWND` type and the `windows.SW_MINIMIZE` (= 6) constant
|
||||||
|
|
||||||
|
Not wrapped by `x/sys/windows`; loaded once via
|
||||||
|
`windows.NewLazySystemDLL("user32.dll")` and called through `proc.Call`:
|
||||||
|
|
||||||
|
- `GetWindowTextW(hwnd, *uint16, max int32) int32` → title
|
||||||
|
- `ShowWindow(hwnd, SW_MINIMIZE) bool`
|
||||||
|
|
||||||
|
Public surface (two helpers the adapters consume):
|
||||||
|
|
||||||
|
- `ForegroundWindow() (hwnd uintptr, title, class string, ok bool)` — resolves
|
||||||
|
the foreground window's title and process-exe-base `class` in one call. `ok`
|
||||||
|
is false when there is no foreground window (null hwnd) — e.g. secure desktop,
|
||||||
|
UAC prompt, lock screen.
|
||||||
|
- `MinimizeForeground() error` — minimizes the current foreground window;
|
||||||
|
returns nil when nothing is focused.
|
||||||
|
|
||||||
|
`class` is derived from the owning process image path
|
||||||
|
(`QueryFullProcessImageName`) as the **base name minus the `.exe` extension,
|
||||||
|
lowercased**: `C:\Program Files\Microsoft VS Code\Code.exe` → `code`. This is
|
||||||
|
the closest analog to X11 `WM_CLASS` conventions and keeps allowed-class lists
|
||||||
|
readable. Path parsing is a pure function (see Testing).
|
||||||
|
|
||||||
|
### `evidence.Source` (Windows) — polling
|
||||||
|
|
||||||
|
`Watch(ctx, onChange)`:
|
||||||
|
|
||||||
|
1. Emit one snapshot immediately (honors the "once immediately" contract).
|
||||||
|
2. `time.NewTicker(750 * time.Millisecond)`.
|
||||||
|
3. On each tick, call `winapi.ForegroundWindow()` and track the last
|
||||||
|
`(hwnd, title)`. Invoke `onChange` **only when `hwnd` or `title` changed** —
|
||||||
|
a steady window stays silent, and a same-window title change (e.g. a browser
|
||||||
|
tab switch) still fires. This reproduces the fidelity the X11 source gets
|
||||||
|
from `_NET_ACTIVE_WINDOW` plus name property notifies.
|
||||||
|
4. Map a successful read to
|
||||||
|
`WindowSnapshot{Title: title, Class: class, Health: {Available: true}}`.
|
||||||
|
5. Map `ok == false` to
|
||||||
|
`WindowSnapshot{Health: {Available: false, Reason: "no foreground window"}}`.
|
||||||
|
Recovers automatically on the next tick.
|
||||||
|
6. On `ctx.Done()`, stop the ticker and return. Nothing else to tear down.
|
||||||
|
|
||||||
|
Rationale for polling over `SetWinEventHook`: under compile-only verification,
|
||||||
|
the responsible choice is the design with no callback trampoline
|
||||||
|
(`syscall.NewCallback`), no `GetMessage`/`DispatchMessage` loop, and no
|
||||||
|
`runtime.LockOSThread` — three failure modes that are hard to validate without a
|
||||||
|
Windows machine. ~1s switch latency is irrelevant for a focus tracker, and
|
||||||
|
polling also catches in-window title changes for free.
|
||||||
|
|
||||||
|
### `enforce.Guard` (Windows)
|
||||||
|
|
||||||
|
`MinimizeActive(ctx)`:
|
||||||
|
|
||||||
|
1. Read the foreground window. If null, return nil (nothing focused — same as
|
||||||
|
the X11 `active == 0` case).
|
||||||
|
2. Otherwise `ShowWindow(hwnd, SW_MINIMIZE)`; on failure return a wrapped error.
|
||||||
|
|
||||||
|
Per-call and stateless, mirroring the short-lived X11 connection model. Errors
|
||||||
|
are for the caller to log; the caller never blocks on enforcement.
|
||||||
|
|
||||||
|
## Error handling and degradation
|
||||||
|
|
||||||
|
The existing contracts are unchanged; this design simply honors them on Windows.
|
||||||
|
|
||||||
|
- Sensor can't read a foreground window → `Available: false` snapshot with a
|
||||||
|
reason. The daemon already treats this as "no evidence this tick" and surfaces
|
||||||
|
the reason in the live view. Auto-recovers on the next tick.
|
||||||
|
- `ShowWindow` fails → error returned and logged upstream; enforcement is
|
||||||
|
best-effort, treated as "did nothing this time." No panic, no block.
|
||||||
|
- No new failure types reach `session`/`web`: they still see only a
|
||||||
|
`WindowSnapshot` and a possible `MinimizeActive` error, exactly as with X11.
|
||||||
|
|
||||||
|
## Known cross-platform nuance (not a bug)
|
||||||
|
|
||||||
|
The same application can produce a different `Class` on Linux vs Windows (X11
|
||||||
|
`WM_CLASS` vs Windows exe base name; e.g. Chrome may be `google-chrome` on Linux
|
||||||
|
and `chrome` on Windows). Allowed-class lists are per-session and case-folded,
|
||||||
|
so this only affects portability of class names *across machines*, never
|
||||||
|
matching within a single session. Documented, not engineered around.
|
||||||
|
|
||||||
|
## Testing and verification
|
||||||
|
|
||||||
|
Bounded honestly by the compile-only constraint.
|
||||||
|
|
||||||
|
1. **Cross-compilation gate (primary automated guarantee).**
|
||||||
|
`GOOS=windows GOARCH=amd64 go build ./...` must pass. Also build
|
||||||
|
`GOOS=darwin` to prove the `!linux && !windows` tag edit still selects the
|
||||||
|
no-op fallback (i.e. the everything-else world is intact).
|
||||||
|
2. **Pure-logic unit tests, no build tag (run on Linux).** The syscall-free
|
||||||
|
logic is extracted into pure functions and tested directly:
|
||||||
|
- exe-path → class normalization: `Code.exe` → `code`, strip `.exe`,
|
||||||
|
lowercase, handle no-extension names and UNC/odd separators.
|
||||||
|
- change-detection predicate: emit on `(hwnd, title)` change, stay silent
|
||||||
|
otherwise.
|
||||||
|
3. **No fabricated syscall mocks.** We will not write a fake that pretends to
|
||||||
|
exercise `user32` — that produces false confidence. Live verification is
|
||||||
|
deferred until a Windows 11 machine is available.
|
||||||
|
4. **`x11_integration_test.go` files stay `//go:build linux`**, untouched.
|
||||||
|
|
||||||
|
### Deferred manual verification (when a Windows 11 machine is available)
|
||||||
|
|
||||||
|
- Run `antidriftd`; confirm the browser opens and the live view shows the
|
||||||
|
current window title/class.
|
||||||
|
- Switch windows and change a browser tab; confirm snapshots update and the
|
||||||
|
health reads available.
|
||||||
|
- Start a commitment with "Enforce focus" armed; focus an off-task window;
|
||||||
|
confirm the drift judge fires and the window minimizes.
|
||||||
|
|
||||||
|
## Out of scope (restated)
|
||||||
|
|
||||||
|
`SetWinEventHook`, Windows ARM64, tray/installer/packaging/autostart, and any
|
||||||
|
session/web/domain changes. This is pure adapter work behind two existing ports.
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
module antidrift
|
module keel
|
||||||
|
|
||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jezek/xgb v1.3.1
|
github.com/jezek/xgb v1.3.1
|
||||||
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
||||||
|
golang.org/x/sys v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -36,7 +37,6 @@ require (
|
|||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// internal/ai/ambient.go
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientDriftJudge judges whether current activity serves the user's standing
|
||||||
|
// frame when NO task is declared. Like Nudge it takes primitives, not domain
|
||||||
|
// types, so ai stays a leaf. The returned string is a one-sentence advisory, or
|
||||||
|
// "" when the activity is plausibly on-track.
|
||||||
|
type AmbientDriftJudge interface {
|
||||||
|
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response.
|
||||||
|
var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift")
|
||||||
|
|
||||||
|
// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend.
|
||||||
|
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parseAmbientDrift(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAmbientPrompt(frame string, recentTitles []string) string {
|
||||||
|
return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame — goals, values, and life-domains — and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift.
|
||||||
|
|
||||||
|
Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"drifting": <true or false>, "message": "<short explanation, one sentence>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- drifting: true only if the recent activity serves none of the frame; false otherwise.
|
||||||
|
- message: one short sentence naming the drift. REQUIRED when drifting is true.
|
||||||
|
|
||||||
|
## Frame
|
||||||
|
` + frame + `
|
||||||
|
|
||||||
|
## Recent window titles (oldest to newest)
|
||||||
|
` + strings.Join(recentTitles, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawAmbientDrift struct {
|
||||||
|
Drifting bool `json:"drifting"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields
|
||||||
|
// "". A drift with no message degrades to "" (silence) rather than an error:
|
||||||
|
// the ambient signal is advisory, so the safe degenerate is to say nothing —
|
||||||
|
// exactly like parseNudge.
|
||||||
|
func parseAmbientDrift(s string) (string, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return "", ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||||
|
}
|
||||||
|
var raw rawAmbientDrift
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||||
|
}
|
||||||
|
if !raw.Drifting {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.Message), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// internal/ai/ambient_test.go
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceAmbientDriftSuccess(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`}
|
||||||
|
line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ambient drift: %v", err)
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
t.Fatal("expected a non-empty drift line")
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "ship keel") {
|
||||||
|
t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "YouTube - Brave") {
|
||||||
|
t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceAmbientDriftBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAmbientDrift(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"on track yields empty", `{"drifting": false, "message": ""}`, "", nil},
|
||||||
|
{"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil},
|
||||||
|
{"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil},
|
||||||
|
{"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil},
|
||||||
|
{"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil},
|
||||||
|
{"empty response", " ", "", ErrEmptyResponse},
|
||||||
|
{"no json", "no braces here", "", ErrNoJSON},
|
||||||
|
{"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseAmbientDrift(tt.in)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("codex: temp file: %w", err)
|
return "", fmt.Errorf("codex: temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OffscreenProposal is the brain's suggestion for one worthwhile thing to do
|
||||||
|
// away from the screen. It is deliberately distinct from Proposal (the focus
|
||||||
|
// coach's commitment shape) to avoid a name collision.
|
||||||
|
type OffscreenProposal struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proposer turns an off-screen brief (goals, life-domain notes, today's tasks)
|
||||||
|
// into a single grounded off-screen action.
|
||||||
|
type Proposer interface {
|
||||||
|
Propose(ctx context.Context, brief string) (OffscreenProposal, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propose makes Service satisfy Proposer over the same backend as Coach.
|
||||||
|
func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildProposePrompt(brief))
|
||||||
|
if err != nil {
|
||||||
|
return OffscreenProposal{}, err
|
||||||
|
}
|
||||||
|
return parseOffscreenProposal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProposePrompt(brief string) string {
|
||||||
|
return `You are an off-screen advisor. Read the brief below — the user's goals, life-domain notes, and today's tasks — and pick ONE worthwhile thing for them to do now, away from the screen.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"next_action": "<one worthwhile off-screen action to do now>", "rationale": "<one short sentence: which goal or value it serves>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- next_action: a single concrete off-screen action, doable now.
|
||||||
|
- rationale: one short sentence naming the goal or value it serves.
|
||||||
|
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
|
||||||
|
|
||||||
|
## Brief
|
||||||
|
` + brief
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOffscreenProposal extracts and validates an OffscreenProposal from raw
|
||||||
|
// CLI output, mirroring parseProposal's error discipline.
|
||||||
|
func parseOffscreenProposal(s string) (OffscreenProposal, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return OffscreenProposal{}, ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return OffscreenProposal{}, ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
|
||||||
|
}
|
||||||
|
var raw OffscreenProposal
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
|
||||||
|
}
|
||||||
|
na := strings.TrimSpace(raw.NextAction)
|
||||||
|
if na == "" {
|
||||||
|
return OffscreenProposal{}, ErrInvalidProposal
|
||||||
|
}
|
||||||
|
return OffscreenProposal{
|
||||||
|
NextAction: na,
|
||||||
|
Rationale: strings.TrimSpace(raw.Rationale),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProposeParsesProposal(t *testing.T) {
|
||||||
|
svc := NewService(&fakeBackend{out: `{"next_action":"call mum","rationale":"people goal"}`})
|
||||||
|
p, err := svc.Propose(context.Background(), "off-screen brief")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Propose: %v", err)
|
||||||
|
}
|
||||||
|
if p.NextAction != "call mum" || p.Rationale != "people goal" {
|
||||||
|
t.Fatalf("proposal = %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).Propose(context.Background(), "brief"); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeUnparseable(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: "I cannot help."}
|
||||||
|
if _, err := NewService(fb).Propose(context.Background(), "brief"); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposePromptIncludesBrief(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"next_action":"a","rationale":"b"}`}
|
||||||
|
if _, err := NewService(fb).Propose(context.Background(), "water the tomato plants"); err != nil {
|
||||||
|
t.Fatalf("Propose: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "water the tomato plants") {
|
||||||
|
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposePromptHasFollowUpRule(t *testing.T) {
|
||||||
|
p := buildProposePrompt("brief")
|
||||||
|
if !strings.Contains(p, "do not simply repeat") {
|
||||||
|
t.Fatalf("prompt missing follow-up rule:\n%s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// internal/ambient/sentinel.go
|
||||||
|
|
||||||
|
// Package ambient is Keel's always-on drift coach: a Sentinel that watches the
|
||||||
|
// window stream beside the one-mode harness and, when no mode is active, judges
|
||||||
|
// recent activity against the ~/owc frame and surfaces drift. It is not a mode —
|
||||||
|
// it runs continuously, including when the harness is idle.
|
||||||
|
package ambient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/frame"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
|
"keel/internal/notify"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recentTitlesMax bounds the title ring (matches the focus mode's history cap).
|
||||||
|
const recentTitlesMax = 10
|
||||||
|
|
||||||
|
// evalTimeout bounds a single ambient brain call.
|
||||||
|
const evalTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// defaultCadence is used when the configured cadence is non-positive.
|
||||||
|
const defaultCadence = 5 * time.Minute
|
||||||
|
|
||||||
|
// kindAmbientNudge is the memory event recorded for a sustained ambient drift.
|
||||||
|
const kindAmbientNudge = "ambient_nudge"
|
||||||
|
|
||||||
|
// Ambient mode dial values (mirror settings.Ambient*).
|
||||||
|
const (
|
||||||
|
modeOff = "off"
|
||||||
|
modeStatus = "status"
|
||||||
|
modeNotify = "notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientDriftJudge is the brain call the sentinel needs. *ai.Service satisfies
|
||||||
|
// it; declaring it here keeps ambient decoupled from the ai package.
|
||||||
|
type AmbientDriftJudge interface {
|
||||||
|
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deps are the ports the sentinel depends on. Knowledge and Tasks may be nil
|
||||||
|
// (frame.Assemble guards). Memory and Notifier may be nil (best-effort guards).
|
||||||
|
// ActiveMode reports the harness's active mode kind ("" = idle); nil counts as
|
||||||
|
// always-idle. Clock defaults to time.Now.
|
||||||
|
type Deps struct {
|
||||||
|
AI AmbientDriftJudge
|
||||||
|
Knowledge knowledge.Source
|
||||||
|
Tasks tasks.Provider
|
||||||
|
Notifier notify.Notifier
|
||||||
|
Memory memory.Store
|
||||||
|
Clock func() time.Time
|
||||||
|
ActiveMode func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel watches activity and surfaces ambient drift.
|
||||||
|
type Sentinel struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
deps Deps
|
||||||
|
cadence time.Duration
|
||||||
|
mode string
|
||||||
|
|
||||||
|
recent []string
|
||||||
|
lastWindow evidence.WindowSnapshot
|
||||||
|
line string
|
||||||
|
lastEvalTitles string
|
||||||
|
drifting bool
|
||||||
|
committed bool
|
||||||
|
snoozeUntil time.Time
|
||||||
|
onChange []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a sentinel. A non-positive cadence falls back to defaultCadence; an
|
||||||
|
// empty mode falls back to notify.
|
||||||
|
func New(d Deps, cadence time.Duration, mode string) *Sentinel {
|
||||||
|
if d.Clock == nil {
|
||||||
|
d.Clock = time.Now
|
||||||
|
}
|
||||||
|
if cadence <= 0 {
|
||||||
|
cadence = defaultCadence
|
||||||
|
}
|
||||||
|
return &Sentinel{deps: d, cadence: cadence, mode: normalizeMode(mode)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMode(m string) string {
|
||||||
|
if m == "" {
|
||||||
|
return modeNotify
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnWindow ingests one sensor observation: it records the latest window and
|
||||||
|
// appends the title to the dedup-capped ring. Cheap and lock-bounded; it fires
|
||||||
|
// no brain call.
|
||||||
|
func (s *Sentinel) OnWindow(w evidence.WindowSnapshot) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.lastWindow = w
|
||||||
|
t := strings.TrimSpace(w.Title)
|
||||||
|
if t == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := len(s.recent); n > 0 && s.recent[n-1] == t {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recent = append(s.recent, t)
|
||||||
|
if len(s.recent) > recentTitlesMax {
|
||||||
|
s.recent = s.recent[len(s.recent)-recentTitlesMax:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line returns the current ambient coaching line ("" when on-track, quiet, or
|
||||||
|
// snoozed) for the surfaces.
|
||||||
|
func (s *Sentinel) Line() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.line
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOnChange registers a surface refresh fired when the line changes.
|
||||||
|
func (s *Sentinel) AddOnChange(f func()) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.onChange = append(s.onChange, f)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireOnChange notifies registered surfaces off the lock.
|
||||||
|
func (s *Sentinel) fireOnChange() {
|
||||||
|
s.mu.Lock()
|
||||||
|
fs := append([]func(){}, s.onChange...)
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, f := range fs {
|
||||||
|
if f != nil {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentForTest returns a copy of the ring (test-only).
|
||||||
|
func (s *Sentinel) recentForTest() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]string(nil), s.recent...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run drives the cadence loop: every cadence it evaluates, until ctx is
|
||||||
|
// cancelled. It re-reads the cadence each cycle so SetConfig takes effect on the
|
||||||
|
// next tick.
|
||||||
|
func (s *Sentinel) Run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(s.currentCadence()):
|
||||||
|
s.evaluate(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sentinel) currentCadence() time.Duration {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.cadence
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig updates the cadence and mode live (next tick).
|
||||||
|
func (s *Sentinel) SetConfig(cadence time.Duration, mode string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if cadence > 0 {
|
||||||
|
s.cadence = cadence
|
||||||
|
}
|
||||||
|
s.mode = normalizeMode(mode)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze mutes the sentinel for d: it clears the current line and episode and
|
||||||
|
// suppresses evaluation until the window elapses.
|
||||||
|
func (s *Sentinel) Snooze(d time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.snoozeUntil = s.deps.Clock().Add(d)
|
||||||
|
changed := s.line != ""
|
||||||
|
s.line = ""
|
||||||
|
s.drifting = false
|
||||||
|
s.committed = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate runs one decision cycle. See the spec's "Trigger discipline."
|
||||||
|
func (s *Sentinel) evaluate(ctx context.Context) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.mode == modeOff {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.deps.Clock().Before(s.snoozeUntil) {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.activeModeLocked() != "" {
|
||||||
|
// A mode is coaching; stay silent and clear any stale line.
|
||||||
|
changed := s.clearLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
titles := append([]string(nil), s.recent...)
|
||||||
|
joined := strings.Join(titles, "\n")
|
||||||
|
// Cheap gate: nothing new to judge and we are on-track -> skip the brain call.
|
||||||
|
if joined == s.lastEvalTitles && s.line == "" {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
win := s.lastWindow
|
||||||
|
mode := s.mode
|
||||||
|
judge := s.deps.AI
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if judge == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, evalTimeout)
|
||||||
|
fr := frame.Assemble(cctx, s.deps.Knowledge, s.deps.Tasks)
|
||||||
|
msg, err := judge.AmbientDrift(cctx, fr, titles)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
// A mode may have activated during the (off-lock) brain call; re-check so a
|
||||||
|
// result computed while idle is not surfaced over an now-active mode.
|
||||||
|
if s.activeModeLocked() != "" {
|
||||||
|
changed := s.clearLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.lastEvalTitles = joined
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
log.Printf("ambient: drift judge failed: %v", err)
|
||||||
|
return // never fabricate drift; leave the prior line intact
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg == "" { // on-track
|
||||||
|
changed := s.clearLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.drifting { // new episode: show the line, no toast yet
|
||||||
|
s.line = msg
|
||||||
|
s.drifting = true
|
||||||
|
s.committed = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.fireOnChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sustained drift. Commit once: record memory, and toast in notify mode.
|
||||||
|
changed := s.line != msg
|
||||||
|
s.line = msg
|
||||||
|
if s.committed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.committed = true
|
||||||
|
now := s.deps.Clock()
|
||||||
|
mem := s.deps.Memory
|
||||||
|
notifier := s.deps.Notifier
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if mem != nil {
|
||||||
|
ev := memory.Event{Kind: kindAmbientNudge, At: now, Data: map[string]any{
|
||||||
|
"message": msg, "title": win.Title, "class": win.Class,
|
||||||
|
}}
|
||||||
|
if e := mem.Record(ctx, ev); e != nil {
|
||||||
|
log.Printf("ambient: memory record: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == modeNotify && notifier != nil {
|
||||||
|
if e := notifier.Notify(ctx, "Keel — drift", msg); e != nil {
|
||||||
|
log.Printf("ambient: notify: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.fireOnChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLocked resets line + episode state and reports whether the line changed.
|
||||||
|
// Caller holds mu.
|
||||||
|
func (s *Sentinel) clearLocked() bool {
|
||||||
|
changed := s.line != ""
|
||||||
|
s.line = ""
|
||||||
|
s.drifting = false
|
||||||
|
s.committed = false
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeModeLocked reads the active-mode kind, treating a nil hook as idle.
|
||||||
|
// Caller holds mu (the hook does no locking on the sentinel).
|
||||||
|
func (s *Sentinel) activeModeLocked() string {
|
||||||
|
if s.deps.ActiveMode == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.deps.ActiveMode()
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// internal/ambient/sentinel_test.go
|
||||||
|
package ambient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func snap(title, class string) evidence.WindowSnapshot {
|
||||||
|
return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLineEmpty(t *testing.T) {
|
||||||
|
s := New(Deps{}, 0, "notify")
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("fresh sentinel line = %q, want empty", s.Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnWindowRingDedupesAndCaps(t *testing.T) {
|
||||||
|
s := New(Deps{}, 0, "notify")
|
||||||
|
s.OnWindow(snap("a", "x"))
|
||||||
|
s.OnWindow(snap("a", "x")) // consecutive dup ignored
|
||||||
|
s.OnWindow(snap("b", "x"))
|
||||||
|
if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||||
|
t.Fatalf("ring = %v, want [a b]", got)
|
||||||
|
}
|
||||||
|
for i := 0; i < recentTitlesMax+5; i++ {
|
||||||
|
s.OnWindow(snap(string(rune('A'+i)), "x"))
|
||||||
|
}
|
||||||
|
if got := s.recentForTest(); len(got) != recentTitlesMax {
|
||||||
|
t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnWindowSkipsEmptyTitle(t *testing.T) {
|
||||||
|
s := New(Deps{}, 0, "notify")
|
||||||
|
s.OnWindow(snap("", "x"))
|
||||||
|
if got := s.recentForTest(); len(got) != 0 {
|
||||||
|
t.Fatalf("empty title must not enter ring, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = context.Background
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
type fakeJudge struct {
|
||||||
|
out []string // queued returns, consumed front-to-back
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.err != nil {
|
||||||
|
return "", f.err
|
||||||
|
}
|
||||||
|
if len(f.out) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
r := f.out[0]
|
||||||
|
f.out = f.out[1:]
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotifier struct {
|
||||||
|
calls int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotifier) Notify(_ context.Context, _, body string) error {
|
||||||
|
f.calls++
|
||||||
|
f.body = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel {
|
||||||
|
return New(Deps{
|
||||||
|
AI: j,
|
||||||
|
Notifier: n,
|
||||||
|
Memory: mem,
|
||||||
|
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
|
||||||
|
ActiveMode: func() string { return *active },
|
||||||
|
}, time.Minute, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryFake is a minimal memory.Store recorder.
|
||||||
|
type memoryFake struct {
|
||||||
|
events []memory.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryFake) Record(_ context.Context, e memory.Event) error {
|
||||||
|
m.events = append(m.events, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil }
|
||||||
|
|
||||||
|
// --- behavioural matrix ---
|
||||||
|
|
||||||
|
func TestEvaluateOnTrackIsSilent(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{""}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("main.go - code", "code"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 {
|
||||||
|
t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"deep in YouTube"}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if s.Line() == "" {
|
||||||
|
t.Fatal("first drift must set the status line")
|
||||||
|
}
|
||||||
|
if n.calls != 0 {
|
||||||
|
t.Fatalf("first drift must NOT toast, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if len(mem.events) != 0 {
|
||||||
|
t.Fatalf("first drift must not record memory yet, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
|
||||||
|
s.evaluate(context.Background()) // first: line, no toast
|
||||||
|
s.evaluate(context.Background()) // second: sustained -> one toast + one memory
|
||||||
|
s.evaluate(context.Background()) // third: still drift -> NO second toast
|
||||||
|
|
||||||
|
if n.calls != 1 {
|
||||||
|
t.Fatalf("sustained drift must toast exactly once, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if len(mem.events) != 1 {
|
||||||
|
t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
if mem.events[0].Kind != kindAmbientNudge {
|
||||||
|
t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift", "drift", ""}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
s.evaluate(context.Background()) // on-track now
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("return to on-track must clear the line, got %q", s.Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateSilentWhenModeActive(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift"}}
|
||||||
|
active := "focus"
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if j.calls != 0 {
|
||||||
|
t.Fatalf("an active mode must suppress the brain call, got %d", j.calls)
|
||||||
|
}
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("an active mode must keep the line empty, got %q", s.Line())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateStatusModeNeverToasts(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift", "drift"}}
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, n, mem, modeStatus, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if n.calls != 0 {
|
||||||
|
t.Fatalf("status mode must never toast, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if s.Line() == "" {
|
||||||
|
t.Fatal("status mode must still show the line")
|
||||||
|
}
|
||||||
|
if len(mem.events) != 1 {
|
||||||
|
t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateOffModeDoesNothing(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift"}}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if j.calls != 0 {
|
||||||
|
t.Fatalf("off mode must not call the brain, got %d", j.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{""}} // one on-track answer available
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("main.go - code", "code"))
|
||||||
|
s.evaluate(context.Background()) // calls=1, on-track
|
||||||
|
s.evaluate(context.Background()) // unchanged ring + on-track -> skip
|
||||||
|
if j.calls != 1 {
|
||||||
|
t.Fatalf("cheap gate must skip the second call, got %d", j.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flippingJudge simulates a harness mode activating DURING the brain call: it
|
||||||
|
// returns a drift verdict but flips the active-mode flag as a side effect, so by
|
||||||
|
// the time evaluate re-acquires the lock a mode is active.
|
||||||
|
type flippingJudge struct{ active *string }
|
||||||
|
|
||||||
|
func (f *flippingJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
|
||||||
|
*f.active = "focus"
|
||||||
|
return "deep in YouTube", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mode activating mid-evaluation must suppress the surface: no line, no toast,
|
||||||
|
// no memory record (coexistence rule holds across the unlock-for-I/O window).
|
||||||
|
func TestEvaluateModeActivatingDuringJudgeStaysSilent(t *testing.T) {
|
||||||
|
active := ""
|
||||||
|
n := &fakeNotifier{}
|
||||||
|
mem := &memoryFake{}
|
||||||
|
s := New(Deps{
|
||||||
|
AI: &flippingJudge{active: &active},
|
||||||
|
Notifier: n,
|
||||||
|
Memory: mem,
|
||||||
|
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
|
||||||
|
ActiveMode: func() string { return active },
|
||||||
|
}, time.Minute, modeNotify)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background())
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("mode active after judge must leave line empty, got %q", s.Line())
|
||||||
|
}
|
||||||
|
if n.calls != 0 {
|
||||||
|
t.Fatalf("mode active after judge must not toast, got %d", n.calls)
|
||||||
|
}
|
||||||
|
if len(mem.events) != 0 {
|
||||||
|
t.Fatalf("mode active after judge must not record memory, got %d", len(mem.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnoozeMutesAndClears(t *testing.T) {
|
||||||
|
j := &fakeJudge{out: []string{"drift", "drift"}}
|
||||||
|
active := ""
|
||||||
|
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
|
||||||
|
s.OnWindow(snap("YouTube - Brave", "Brave"))
|
||||||
|
s.evaluate(context.Background()) // line set
|
||||||
|
s.Snooze(time.Hour)
|
||||||
|
if s.Line() != "" {
|
||||||
|
t.Fatalf("snooze must clear the line, got %q", s.Line())
|
||||||
|
}
|
||||||
|
callsBefore := j.calls
|
||||||
|
s.evaluate(context.Background()) // snoozed -> no call
|
||||||
|
if j.calls != callsBefore {
|
||||||
|
t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Package aw is a thin ActivityWatch REST client: it creates buckets and reads
|
||||||
|
// and writes events. It holds no Keel concepts, so it stays a leaf package.
|
||||||
|
package aw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete
|
||||||
|
// derived events it is 0.
|
||||||
|
type Event struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Duration float64
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client talks to an aw-server over its /api/0 REST surface.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client for the given base URL (e.g. http://localhost:5600).
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
hc: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists
|
||||||
|
// response (304) is success.
|
||||||
|
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"client": client,
|
||||||
|
"type": eventType,
|
||||||
|
"hostname": hostname(),
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostname() string {
|
||||||
|
if h, err := os.Hostname(); err == nil && h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return "keel"
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventJSON struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert posts a single event to the bucket. The AW events endpoint takes a
|
||||||
|
// JSON array, so the event is wrapped in a one-element list.
|
||||||
|
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error {
|
||||||
|
body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns up to limit events from the bucket, newest first (the AW
|
||||||
|
// default ordering for the events endpoint).
|
||||||
|
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode)
|
||||||
|
}
|
||||||
|
var raw []eventJSON
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Event, len(raw))
|
||||||
|
for i, e := range raw {
|
||||||
|
out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package aw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureBucketPostsCreate(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotBody map[string]string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL)
|
||||||
|
if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||||
|
t.Fatalf("EnsureBucket: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/0/buckets/keel.events" {
|
||||||
|
t.Fatalf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" {
|
||||||
|
t.Fatalf("body = %+v", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBucketTreatsExistsAsOK(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||||
|
t.Fatalf("existing bucket should be OK, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBucketErrorsOnServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil {
|
||||||
|
t.Fatal("expected error on 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertPostsEventArray(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotEvents []map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &gotEvents)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL)
|
||||||
|
ev := Event{Timestamp: time.Unix(1000, 0).UTC(), Data: map[string]any{"_kind": "proposal_made"}}
|
||||||
|
if err := c.Insert(context.Background(), "keel.events", ev); err != nil {
|
||||||
|
t.Fatalf("Insert: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/0/buckets/keel.events/events" {
|
||||||
|
t.Fatalf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if len(gotEvents) != 1 || gotEvents[0]["data"].(map[string]any)["_kind"] != "proposal_made" {
|
||||||
|
t.Fatalf("events = %+v", gotEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentDecodesNewestFirst(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("limit"); got != "5" {
|
||||||
|
t.Errorf("limit = %q, want 5", got)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `[
|
||||||
|
{"id":2,"timestamp":"2026-06-05T10:00:00+00:00","duration":0,"data":{"_kind":"proposal_made"}},
|
||||||
|
{"id":1,"timestamp":"2026-06-04T10:00:00+00:00","duration":0,"data":{"_kind":"action_taken"}}
|
||||||
|
]`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := New(srv.URL).Recent(context.Background(), "keel.events", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Data["_kind"] != "proposal_made" {
|
||||||
|
t.Fatalf("got = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !linux
|
//go:build !linux && !windows
|
||||||
|
|
||||||
package enforce
|
package enforce
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"keel/internal/winapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewGuard returns the Windows window-minimize guard.
|
||||||
|
func NewGuard() Guard { return windowsGuard{} }
|
||||||
|
|
||||||
|
type windowsGuard struct{}
|
||||||
|
|
||||||
|
// MinimizeActive minimizes the current foreground window. It is best-effort:
|
||||||
|
// with nothing focused it does nothing and returns nil. Stateless and per-call,
|
||||||
|
// with no connection or shared state to manage. ShowWindow does not report a
|
||||||
|
// minimize failure, so this always returns nil today.
|
||||||
|
func (windowsGuard) MinimizeActive(_ context.Context) error {
|
||||||
|
return winapi.MinimizeForeground()
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package evidence
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
||||||
@@ -19,7 +19,10 @@ func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, allowed := range ctx.WindowClasses {
|
for _, allowed := range ctx.WindowClasses {
|
||||||
if normalizeCasefolded(allowed) == candidate {
|
// Substring, not equality: a short token a user types ("brave") matches the
|
||||||
|
// longer real WM_CLASS ("Brave-browser"). Equality silently disabled the
|
||||||
|
// fast path whenever the two differed, routing every window to the judge.
|
||||||
|
if a := normalizeCasefolded(allowed); a != "" && strings.Contains(candidate, a) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package evidence
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
||||||
@@ -19,6 +19,20 @@ func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWindowClassMatchesSubstring(t *testing.T) {
|
||||||
|
// Users type a short app name ("brave") but the real WM_CLASS is longer
|
||||||
|
// ("Brave-browser"). The allowed token matching as a substring of the actual
|
||||||
|
// class keeps the local on-task fast path working instead of silently routing
|
||||||
|
// every window to the LLM judge.
|
||||||
|
ctx := domain.AllowedContext{WindowClasses: []string{"brave"}}
|
||||||
|
if !windowClassAllowed(ctx, "Brave-browser") {
|
||||||
|
t.Fatal("short allowed token should match the longer real class")
|
||||||
|
}
|
||||||
|
if windowClassAllowed(ctx, "firefox") {
|
||||||
|
t.Fatal("unrelated class must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWindowTitleMatchesSubstring(t *testing.T) {
|
func TestWindowTitleMatchesSubstring(t *testing.T) {
|
||||||
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
|
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
|
||||||
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
|
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
|
||||||
|
|||||||
@@ -24,14 +24,15 @@ type WindowSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Source is the activity port. Watch runs until ctx is cancelled, invoking
|
// Source is the activity port. Watch runs until ctx is cancelled, invoking
|
||||||
// onChange on every active-window change, and once immediately with the
|
// onChange once immediately with the current window and then on every change of
|
||||||
// current window.
|
// the active window or its title (a tab switch changes the title but not the
|
||||||
|
// active window).
|
||||||
type Source interface {
|
type Source interface {
|
||||||
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket
|
// titleNoise matches the clock/ratio/percent runs that pollute bucket
|
||||||
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl.
|
// keys (e.g. "1:23", "50.5%", "-3.0").
|
||||||
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
|
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
|
||||||
|
|
||||||
// extraSpace collapses the whitespace runs left behind by the scrubs below.
|
// extraSpace collapses the whitespace runs left behind by the scrubs below.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
// foregroundTracker remembers the last observed foreground window so the
|
||||||
|
// Windows polling Source emits only on change. hwnd is held as uintptr so this
|
||||||
|
// file stays platform-neutral (it must build and test on Linux).
|
||||||
|
type foregroundTracker struct {
|
||||||
|
primed bool
|
||||||
|
available bool
|
||||||
|
hwnd uintptr
|
||||||
|
title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// changed reports whether (available, hwnd, title) differs from the last
|
||||||
|
// observation and records the new values. The first call always returns true.
|
||||||
|
// Callers pass the zero values (hwnd 0, title "") when available is false, so a
|
||||||
|
// steady "no foreground window" run does not re-emit.
|
||||||
|
func (t *foregroundTracker) changed(available bool, hwnd uintptr, title string) bool {
|
||||||
|
if t.primed && available == t.available && hwnd == t.hwnd && title == t.title {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.primed, t.available, t.hwnd, t.title = true, available, hwnd, title
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestForegroundTrackerChanged(t *testing.T) {
|
||||||
|
var tr foregroundTracker
|
||||||
|
|
||||||
|
if !tr.changed(true, 100, "A") {
|
||||||
|
t.Fatal("first observation should always report changed")
|
||||||
|
}
|
||||||
|
if tr.changed(true, 100, "A") {
|
||||||
|
t.Error("identical observation should not report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(true, 100, "B") {
|
||||||
|
t.Error("title change on same hwnd should report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(true, 200, "B") {
|
||||||
|
t.Error("hwnd change should report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(false, 0, "") {
|
||||||
|
t.Error("transition to unavailable should report changed")
|
||||||
|
}
|
||||||
|
if tr.changed(false, 0, "") {
|
||||||
|
t.Error("repeated unavailable should not report changed")
|
||||||
|
}
|
||||||
|
if !tr.changed(true, 200, "B") {
|
||||||
|
t.Error("transition back to available should report changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pollLoop samples the active window via read and forwards snapshots through
|
||||||
|
// onChange, but only when the observation changes — a different window OR a
|
||||||
|
// different title. The title case is the one that matters: switching a browser
|
||||||
|
// tab changes the window title without changing the active window, so a sensor
|
||||||
|
// that re-reads only on active-window events never sees it and credits the stale
|
||||||
|
// title. Polling re-reads on a fixed cadence, so a tab switch is caught within
|
||||||
|
// one interval.
|
||||||
|
//
|
||||||
|
// It emits once immediately, then samples every interval, until ctx is
|
||||||
|
// cancelled. WindowSnapshot is comparable, so the change check is a plain
|
||||||
|
// equality. Factoring the loop here keeps the change-dedup identical across
|
||||||
|
// sensors and lets it be tested without a display. (The Windows sensor predates
|
||||||
|
// this and keeps its own equivalent loop.)
|
||||||
|
func pollLoop(ctx context.Context, interval time.Duration, read func() WindowSnapshot, onChange func(WindowSnapshot)) {
|
||||||
|
var last WindowSnapshot
|
||||||
|
var haveLast bool
|
||||||
|
emit := func() {
|
||||||
|
s := read()
|
||||||
|
if haveLast && s == last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last, haveLast = s, true
|
||||||
|
onChange(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit() // immediate current window
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug
|
||||||
|
// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and
|
||||||
|
// so missed a browser tab switch — same window class, new title — crediting all
|
||||||
|
// the time to the stale title. The poll loop must emit when only the title
|
||||||
|
// changes.
|
||||||
|
func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) {
|
||||||
|
// Same window (class) throughout; the title changes mid-stream, like a tab
|
||||||
|
// switch. The repeated identical reads must NOT re-emit; the title change must.
|
||||||
|
titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"}
|
||||||
|
var i int
|
||||||
|
read := func() WindowSnapshot {
|
||||||
|
idx := i
|
||||||
|
if idx >= len(titles) {
|
||||||
|
idx = len(titles) - 1
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var got []WindowSnapshot
|
||||||
|
onChange := func(s WindowSnapshot) {
|
||||||
|
mu.Lock()
|
||||||
|
got = append(got, s)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
n := len(got)
|
||||||
|
mu.Unlock()
|
||||||
|
if n >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
t.Fatalf("expected >=2 emits (one per distinct title), got %d", n)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if got[0].Title != "Keel - Brave" {
|
||||||
|
t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave")
|
||||||
|
}
|
||||||
|
if got[1].Title != "Consume - Brave" {
|
||||||
|
t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is
|
||||||
|
// not re-emitted, so redundant samples never churn the consumer.
|
||||||
|
func TestPollLoopDedupesIdenticalObservations(t *testing.T) {
|
||||||
|
read := func() WindowSnapshot {
|
||||||
|
return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var n int
|
||||||
|
onChange := func(WindowSnapshot) {
|
||||||
|
mu.Lock()
|
||||||
|
n++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("identical observations must emit once, got %d emits", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !linux
|
//go:build !linux && !windows
|
||||||
|
|
||||||
package evidence
|
package evidence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
// unavailable builds an Unavailable snapshot and logs the reason. It is shared
|
||||||
|
// by every platform sensor so an unobservable window is recorded consistently.
|
||||||
|
func unavailable(reason string) WindowSnapshot {
|
||||||
|
log.Printf("evidence: %s", reason)
|
||||||
|
return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/winapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pollInterval is how often the Windows sensor samples the foreground window.
|
||||||
|
// ~1s latency on a window switch is immaterial for a focus tracker, and polling
|
||||||
|
// avoids the message-loop/callback machinery a SetWinEventHook source needs.
|
||||||
|
const pollInterval = 750 * time.Millisecond
|
||||||
|
|
||||||
|
// NewSource returns the Windows active-window sensor (polling).
|
||||||
|
func NewSource() Source { return windowsSource{} }
|
||||||
|
|
||||||
|
type windowsSource struct{}
|
||||||
|
|
||||||
|
// Watch emits the current window immediately, then samples every pollInterval,
|
||||||
|
// emitting only when the foreground window or its title changes. A read with no
|
||||||
|
// foreground window yields an Unavailable snapshot (once, until it recovers). It
|
||||||
|
// runs until ctx is cancelled. It never panics the daemon.
|
||||||
|
func (windowsSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||||
|
var tr foregroundTracker
|
||||||
|
poll := func() {
|
||||||
|
hwnd, title, class, ok := winapi.ForegroundWindow()
|
||||||
|
if !tr.changed(ok, hwnd, title) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
onChange(unavailable("no foreground window"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(WindowSnapshot{Title: title, Class: class, Health: EvidenceHealth{Available: true}})
|
||||||
|
}
|
||||||
|
|
||||||
|
poll() // immediate current window
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-43
@@ -4,25 +4,29 @@ package evidence
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"time"
|
||||||
|
|
||||||
"github.com/jezek/xgb/xproto"
|
|
||||||
"github.com/jezek/xgbutil"
|
"github.com/jezek/xgbutil"
|
||||||
"github.com/jezek/xgbutil/ewmh"
|
"github.com/jezek/xgbutil/ewmh"
|
||||||
"github.com/jezek/xgbutil/icccm"
|
"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.
|
// NewSource returns the real X11 active-window sensor.
|
||||||
func NewSource() Source { return &x11Source{} }
|
func NewSource() Source { return &x11Source{} }
|
||||||
|
|
||||||
type x11Source struct{}
|
type x11Source struct{}
|
||||||
|
|
||||||
// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW
|
// Watch opens one long-lived X connection and samples the active window every
|
||||||
// changes on the root window, and emits a snapshot immediately plus on every
|
// pollInterval, emitting a snapshot whenever the active window OR its title
|
||||||
// change. Any failure degrades to an Unavailable snapshot; it never panics the
|
// changes. Any failure degrades to an Unavailable snapshot; it never panics the
|
||||||
// daemon.
|
// daemon.
|
||||||
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||||
X, err := xgbutil.NewConn()
|
X, err := xgbutil.NewConn()
|
||||||
@@ -33,38 +37,11 @@ func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
|||||||
}
|
}
|
||||||
defer X.Conn().Close()
|
defer X.Conn().Close()
|
||||||
|
|
||||||
root := X.RootWin()
|
pollLoop(ctx, pollInterval, func() WindowSnapshot { return snapshot(X) }, onChange)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
||||||
active, err := ewmh.ActiveWindowGet(X)
|
active, err := ewmh.ActiveWindowGet(X)
|
||||||
if err != nil || active == 0 {
|
if err != nil || active == 0 {
|
||||||
@@ -87,8 +64,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
|||||||
Health: EvidenceHealth{Available: true},
|
Health: EvidenceHealth{Available: true},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func unavailable(reason string) WindowSnapshot {
|
|
||||||
log.Printf("evidence: %s", reason)
|
|
||||||
return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// internal/frame/frame.go
|
||||||
|
|
||||||
|
// Package frame assembles the standing "what matters to Felix" brief — today's
|
||||||
|
// tasks plus the ~/owc goals and life-domain notes — that grounds a brain call.
|
||||||
|
// It is shared by the off-screen mode (which adds proposal history) and the
|
||||||
|
// ambient sentinel. Best-effort throughout: it never errors and tolerates nil
|
||||||
|
// ports and missing files.
|
||||||
|
package frame
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goalsPath is the standing-goals note loaded for the brief.
|
||||||
|
const goalsPath = "~/owc/goals-2026.md"
|
||||||
|
|
||||||
|
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
|
||||||
|
const bugGlob = "bug-*.md"
|
||||||
|
|
||||||
|
// MaxBriefBytes caps the assembled brief so the prompt stays bounded.
|
||||||
|
const MaxBriefBytes = 8 * 1024
|
||||||
|
|
||||||
|
// Assemble builds the frame brief from today's tasks, the standing goals note,
|
||||||
|
// and the life-domain notes. It never panics and never errors; nil ports and
|
||||||
|
// missing files degrade to a short, mostly-empty brief.
|
||||||
|
func Assemble(ctx context.Context, know knowledge.Source, tasksProvider tasks.Provider) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("## Today's tasks\n")
|
||||||
|
b.WriteString(briefTasks(ctx, tasksProvider))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if goals := briefGoals(ctx, know); goals != "" {
|
||||||
|
b.WriteString("## Goals\n")
|
||||||
|
b.WriteString(goals)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if domains := briefLifeDomains(ctx, know); domains != "" {
|
||||||
|
b.WriteString("## Life domains\n")
|
||||||
|
b.WriteString(domains)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Truncate(b.String(), MaxBriefBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefTasks lists today's task titles, or "(none)" when the port is absent,
|
||||||
|
// errors, or returns nothing.
|
||||||
|
func briefTasks(ctx context.Context, tp tasks.Provider) string {
|
||||||
|
if tp == nil {
|
||||||
|
return "(none)\n"
|
||||||
|
}
|
||||||
|
list, err := tp.Today(ctx)
|
||||||
|
if err != nil || len(list) == 0 {
|
||||||
|
return "(none)\n"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, t := range list {
|
||||||
|
title := strings.TrimSpace(t.Title)
|
||||||
|
if title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("- ")
|
||||||
|
b.WriteString(title)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return "(none)\n"
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefGoals returns the goals note text, or "" when the port is absent or the
|
||||||
|
// file is missing/empty.
|
||||||
|
func briefGoals(ctx context.Context, know knowledge.Source) string {
|
||||||
|
if know == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
p, err := know.Load(ctx, goalsPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(p.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
|
||||||
|
// port (so reads honor its truncation) and concatenates their text. Returns ""
|
||||||
|
// when the port is absent or no notes are readable.
|
||||||
|
func briefLifeDomains(ctx context.Context, know knowledge.Source) string {
|
||||||
|
if know == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
|
||||||
|
if err != nil || len(matches) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, path := range matches {
|
||||||
|
p, err := know.Load(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(p.Text)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("### ")
|
||||||
|
b.WriteString(filepath.Base(path))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(text)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate clips s to at most max bytes with a marker, backing up to a rune
|
||||||
|
// boundary so it never splits a multibyte rune.
|
||||||
|
func Truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := max
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
return s[:cut] + "\n…(truncated)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// internal/frame/frame_test.go
|
||||||
|
package frame
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTasks is a tasks.Provider returning a fixed list.
|
||||||
|
type fakeTasks struct{ list []tasks.Task }
|
||||||
|
|
||||||
|
// emptyKnowledge is a knowledge.Source that returns no text for any path, so
|
||||||
|
// Assemble emits neither a Goals nor a Life-domains section regardless of what
|
||||||
|
// files exist on the host.
|
||||||
|
type emptyKnowledge struct{}
|
||||||
|
|
||||||
|
func (emptyKnowledge) Load(context.Context, string) (knowledge.Profile, error) {
|
||||||
|
return knowledge.Profile{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeTasks) Today(context.Context) ([]tasks.Task, error) { return f.list, nil }
|
||||||
|
func (f fakeTasks) Create(context.Context, tasks.Task) error { return nil }
|
||||||
|
|
||||||
|
func TestAssembleListsTodaysTasks(t *testing.T) {
|
||||||
|
tp := fakeTasks{list: []tasks.Task{{Title: "write the keel spec"}}}
|
||||||
|
got := Assemble(context.Background(), nil, tp)
|
||||||
|
if !strings.Contains(got, "## Today's tasks") {
|
||||||
|
t.Fatalf("missing tasks header:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "write the keel spec") {
|
||||||
|
t.Fatalf("missing task title:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no knowledge source and no tasks, Assemble degrades to a short brief and
|
||||||
|
// never panics — both ports may be nil.
|
||||||
|
func TestAssembleToleratesNilPorts(t *testing.T) {
|
||||||
|
got := Assemble(context.Background(), nil, nil)
|
||||||
|
if !strings.Contains(got, "## Today's tasks") {
|
||||||
|
t.Fatalf("expected a tasks header even with no ports:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "(none)") {
|
||||||
|
t.Fatalf("expected (none) for absent tasks:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleWithEmptyKnowledge(t *testing.T) {
|
||||||
|
got := Assemble(context.Background(), emptyKnowledge{}, fakeTasks{})
|
||||||
|
if strings.Contains(got, "## Goals") {
|
||||||
|
t.Fatalf("empty knowledge should not emit a Goals section:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "## Life domains") {
|
||||||
|
t.Fatalf("empty knowledge should not emit a Life domains section:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateClipsWithMarker(t *testing.T) {
|
||||||
|
in := strings.Repeat("x", MaxBriefBytes+100)
|
||||||
|
got := Truncate(in, MaxBriefBytes)
|
||||||
|
if len(got) > MaxBriefBytes+len("\n…(truncated)") {
|
||||||
|
t.Fatalf("truncate did not clip: len=%d", len(got))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "(truncated)") {
|
||||||
|
t.Fatalf("missing truncation marker: %q", got[len(got)-20:])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Package harness is the generic host: it owns shared services and exactly one
|
||||||
|
// active mode, and routes evidence, expiry, and commands to it.
|
||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Async runs generation-guarded background work for a mode. It preserves the
|
||||||
|
// lock discipline of the original session.runFetchAsync: fetch runs with no
|
||||||
|
// lock held; stale and apply run under the mode's mutex; notify fires once,
|
||||||
|
// after the lock is released.
|
||||||
|
type Async struct {
|
||||||
|
mu *sync.Mutex
|
||||||
|
notify func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAsync binds the helper to a mode's mutex and the harness change-notify.
|
||||||
|
func NewAsync(mu *sync.Mutex, notify func()) Async {
|
||||||
|
return Async{mu: mu, notify: notify}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run launches a background fetch. stale returns true to DISCARD the result
|
||||||
|
// (a newer generation superseded it). apply records a non-stale result under
|
||||||
|
// the mutex and must NOT call notify — Run owns the post-unlock notify.
|
||||||
|
func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
fetch(ctx)
|
||||||
|
a.mu.Lock()
|
||||||
|
if stale() {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
a.mu.Unlock()
|
||||||
|
if a.notify != nil {
|
||||||
|
a.notify()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAsyncRunAppliesAndNotifies(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
notified := make(chan struct{}, 1)
|
||||||
|
a := NewAsync(&mu, func() { notified <- struct{}{} })
|
||||||
|
|
||||||
|
var applied bool
|
||||||
|
a.Run(time.Second,
|
||||||
|
func(ctx context.Context) {},
|
||||||
|
func() bool { return false },
|
||||||
|
func() { applied = true },
|
||||||
|
)
|
||||||
|
select {
|
||||||
|
case <-notified:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("notify never fired")
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !applied {
|
||||||
|
t.Fatal("apply did not run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") })
|
||||||
|
done := make(chan struct{})
|
||||||
|
a.Run(time.Second,
|
||||||
|
func(ctx context.Context) {},
|
||||||
|
func() bool { close(done); return true },
|
||||||
|
func() { t.Fatal("apply ran on stale result") },
|
||||||
|
)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("stale guard never evaluated")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBusy = errors.New("harness: another mode is active")
|
||||||
|
ErrUnknownMode = errors.New("harness: unknown mode")
|
||||||
|
ErrIdle = errors.New("harness: no active mode")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Factory builds a mode from the shared services, in its initial active state.
|
||||||
|
type Factory func(Services) mode.Mode
|
||||||
|
|
||||||
|
type Harness struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
active mode.Mode
|
||||||
|
factories map[string]Factory
|
||||||
|
services Services
|
||||||
|
onChange []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New wires Services.Notify to the harness so every mode's async completion
|
||||||
|
// reaches all listeners, then stores the services for the factories to receive.
|
||||||
|
func New(services Services, factories map[string]Factory) *Harness {
|
||||||
|
h := &Harness{factories: factories}
|
||||||
|
services.Notify = h.notify
|
||||||
|
h.services = services
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOnChange registers a change listener (web broadcaster, status writer).
|
||||||
|
func (h *Harness) AddOnChange(f func()) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.onChange = append(h.onChange, f)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetServices swaps the services future Start calls hand to factories. In v1 it
|
||||||
|
// affects the NEXT mode start only; an already-active mode keeps the services it
|
||||||
|
// was built with (settings changes take effect on the next session).
|
||||||
|
func (h *Harness) SetServices(s Services) {
|
||||||
|
s.Notify = h.notify
|
||||||
|
h.mu.Lock()
|
||||||
|
h.services = s
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services returns the shared services the harness hands to factories, with
|
||||||
|
// Notify wired to the harness change fan-out. Use it to build a mode for Adopt
|
||||||
|
// (e.g. startup restoration) so the restored mode's async work reaches the
|
||||||
|
// harness's onChange listeners.
|
||||||
|
func (h *Harness) Services() Services {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.services
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) notify() {
|
||||||
|
h.mu.Lock()
|
||||||
|
fs := append([]func(){}, h.onChange...)
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, f := range fs {
|
||||||
|
if f != nil {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopt installs an already-constructed mode as active (startup restoration).
|
||||||
|
// It fails if a mode is already active.
|
||||||
|
func (h *Harness) Adopt(m mode.Mode) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.active != nil {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return ErrBusy
|
||||||
|
}
|
||||||
|
h.active = m
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start activates a mode by kind. Idempotent if that kind is already active;
|
||||||
|
// ErrBusy if a different kind is active.
|
||||||
|
func (h *Harness) Start(kind string) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.active != nil {
|
||||||
|
busy := h.active.Kind() != kind
|
||||||
|
h.mu.Unlock()
|
||||||
|
if busy {
|
||||||
|
return ErrBusy
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, ok := h.factories[kind]
|
||||||
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return ErrUnknownMode
|
||||||
|
}
|
||||||
|
h.active = f(h.services)
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command routes to the active mode, then releases it if it went inactive.
|
||||||
|
func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
m := h.active
|
||||||
|
h.mu.Unlock()
|
||||||
|
if m == nil {
|
||||||
|
return ErrIdle
|
||||||
|
}
|
||||||
|
if err := m.Command(ctx, name, payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.releaseIfDone()
|
||||||
|
h.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) releaseIfDone() {
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.active != nil && !h.active.Active() {
|
||||||
|
h.active = nil
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// State returns the surfacing envelope.
|
||||||
|
func (h *Harness) State() mode.Envelope {
|
||||||
|
h.mu.Lock()
|
||||||
|
m := h.active
|
||||||
|
h.mu.Unlock()
|
||||||
|
if m == nil {
|
||||||
|
return mode.Envelope{}
|
||||||
|
}
|
||||||
|
return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWindow forwards a window snapshot iff the active mode consumes evidence.
|
||||||
|
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) {
|
||||||
|
h.mu.Lock()
|
||||||
|
m := h.active
|
||||||
|
h.mu.Unlock()
|
||||||
|
if c, ok := m.(mode.EvidenceConsumer); ok {
|
||||||
|
c.OnWindow(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline returns the active mode's deadline iff it is an Expirer.
|
||||||
|
func (h *Harness) Deadline() time.Time {
|
||||||
|
h.mu.Lock()
|
||||||
|
m := h.active
|
||||||
|
h.mu.Unlock()
|
||||||
|
if e, ok := m.(mode.Expirer); ok {
|
||||||
|
return e.Deadline()
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire fires the active mode's expiry iff it is an Expirer, then releases it
|
||||||
|
// if it went inactive.
|
||||||
|
func (h *Harness) Expire() error {
|
||||||
|
h.mu.Lock()
|
||||||
|
m := h.active
|
||||||
|
h.mu.Unlock()
|
||||||
|
e, ok := m.(mode.Expirer)
|
||||||
|
if !ok {
|
||||||
|
return ErrIdle
|
||||||
|
}
|
||||||
|
if err := e.Expire(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.releaseIfDone()
|
||||||
|
h.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeMode implements Mode and (optionally) the capability interfaces.
|
||||||
|
type fakeMode struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
kind string
|
||||||
|
active bool
|
||||||
|
cmds []string
|
||||||
|
windows int
|
||||||
|
deadline time.Time
|
||||||
|
expired bool
|
||||||
|
evidence bool // implements EvidenceConsumer when true
|
||||||
|
expirer bool // implements Expirer when true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMode) Kind() string { return f.kind }
|
||||||
|
func (f *fakeMode) View() any { return map[string]bool{"active": f.active} }
|
||||||
|
func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active }
|
||||||
|
func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.cmds = append(f.cmds, name)
|
||||||
|
if name == "finish" {
|
||||||
|
f.active = false
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type evidenceMode struct{ *fakeMode }
|
||||||
|
|
||||||
|
func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() }
|
||||||
|
|
||||||
|
type expirerMode struct{ *fakeMode }
|
||||||
|
|
||||||
|
func (e expirerMode) Deadline() time.Time { return e.deadline }
|
||||||
|
func (e expirerMode) Expire() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.expired = true
|
||||||
|
e.active = false
|
||||||
|
e.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHarness(m mode.Mode) *Harness {
|
||||||
|
return New(Services{Clock: time.Now}, map[string]Factory{
|
||||||
|
m.Kind(): func(Services) mode.Mode { return m },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartSingleActiveInvariant(t *testing.T) {
|
||||||
|
h := New(Services{}, map[string]Factory{
|
||||||
|
"a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} },
|
||||||
|
"b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} },
|
||||||
|
})
|
||||||
|
if err := h.Start("a"); err != nil {
|
||||||
|
t.Fatalf("Start a: %v", err)
|
||||||
|
}
|
||||||
|
if err := h.Start("a"); err != nil {
|
||||||
|
t.Fatalf("re-Start a should be idempotent: %v", err)
|
||||||
|
}
|
||||||
|
if err := h.Start("b"); err != ErrBusy {
|
||||||
|
t.Fatalf("Start b while a active = %v, want ErrBusy", err)
|
||||||
|
}
|
||||||
|
if got := h.State().ActiveMode; got != "a" {
|
||||||
|
t.Fatalf("ActiveMode = %q, want a", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandReleasesWhenInactive(t *testing.T) {
|
||||||
|
m := &fakeMode{kind: "a", active: true}
|
||||||
|
h := newHarness(m)
|
||||||
|
_ = h.Start("a")
|
||||||
|
if err := h.Command(context.Background(), "finish", nil); err != nil {
|
||||||
|
t.Fatalf("Command: %v", err)
|
||||||
|
}
|
||||||
|
if got := h.State().ActiveMode; got != "" {
|
||||||
|
t.Fatalf("after finish ActiveMode = %q, want idle", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) {
|
||||||
|
plain := &fakeMode{kind: "plain", active: true}
|
||||||
|
h := newHarness(plain)
|
||||||
|
_ = h.Start("plain")
|
||||||
|
h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic
|
||||||
|
if plain.windows != 0 {
|
||||||
|
t.Fatalf("plain mode received %d windows, want 0", plain.windows)
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := evidenceMode{&fakeMode{kind: "ev", active: true}}
|
||||||
|
h2 := newHarness(ev)
|
||||||
|
_ = h2.Start("ev")
|
||||||
|
h2.RecordWindow(evidence.WindowSnapshot{})
|
||||||
|
if ev.windows != 1 {
|
||||||
|
t.Fatalf("evidence mode received %d windows, want 1", ev.windows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryOnlyForExpirers(t *testing.T) {
|
||||||
|
plain := &fakeMode{kind: "plain", active: true}
|
||||||
|
h := newHarness(plain)
|
||||||
|
_ = h.Start("plain")
|
||||||
|
if err := h.Expire(); err != ErrIdle {
|
||||||
|
t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err)
|
||||||
|
}
|
||||||
|
if !h.Deadline().IsZero() {
|
||||||
|
t.Fatal("non-expirer Deadline should be zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}}
|
||||||
|
h2 := newHarness(ex)
|
||||||
|
_ = h2.Start("ex")
|
||||||
|
if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) {
|
||||||
|
t.Fatalf("Deadline = %v, want unix 100", got)
|
||||||
|
}
|
||||||
|
if err := h2.Expire(); err != nil {
|
||||||
|
t.Fatalf("Expire: %v", err)
|
||||||
|
}
|
||||||
|
if got := h2.State().ActiveMode; got != "" {
|
||||||
|
t.Fatalf("after Expire ActiveMode = %q, want idle", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandWhileIdle(t *testing.T) {
|
||||||
|
h := New(Services{}, map[string]Factory{})
|
||||||
|
if err := h.Command(context.Background(), "x", nil); err != ErrIdle {
|
||||||
|
t.Fatalf("Command while idle = %v, want ErrIdle", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicesNotifyReachesOnChangeListeners(t *testing.T) {
|
||||||
|
h := New(Services{}, map[string]Factory{})
|
||||||
|
fired := make(chan struct{}, 1)
|
||||||
|
h.AddOnChange(func() { fired <- struct{}{} })
|
||||||
|
svc := h.Services()
|
||||||
|
if svc.Notify == nil {
|
||||||
|
t.Fatal("Services().Notify is nil; restore path would not propagate updates")
|
||||||
|
}
|
||||||
|
svc.Notify() // simulate a restored mode's async completion firing notify
|
||||||
|
select {
|
||||||
|
case <-fired:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Services().Notify did not reach the registered onChange listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/enforce"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Services is the shared infrastructure every mode receives from the harness.
|
||||||
|
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
|
||||||
|
// modes build their own file paths under it. Notify fires the harness change
|
||||||
|
// listeners (web SSE + status bar). Memory is Keel's durable cross-run event
|
||||||
|
// store (AW-backed, or a nop when AW is down).
|
||||||
|
type Services struct {
|
||||||
|
AI *ai.Service
|
||||||
|
Tasks tasks.Provider
|
||||||
|
Knowledge knowledge.Source
|
||||||
|
Enforce enforce.Guard
|
||||||
|
Memory memory.Store
|
||||||
|
Clock func() time.Time
|
||||||
|
Dir string
|
||||||
|
Notify func()
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ type FileSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewFileSource builds the adapter. defaultPath is used when Load receives an
|
// 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 {
|
func NewFileSource(defaultPath string) *FileSource {
|
||||||
return &FileSource{defaultPath: defaultPath}
|
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
|
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.
|
// a leading ~; and makes the result absolute for stable display.
|
||||||
func (s *FileSource) resolve(path string) string {
|
func (s *FileSource) resolve(path string) string {
|
||||||
p := path
|
p := path
|
||||||
@@ -55,7 +55,7 @@ func (s *FileSource) resolve(path string) string {
|
|||||||
}
|
}
|
||||||
if p == "" {
|
if p == "" {
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
p = filepath.Join(home, ".antidrift", "knowledge.md")
|
p = filepath.Join(home, ".keel", "knowledge.md")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p = expandTilde(p)
|
p = expandTilde(p)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
|
||||||
|
// events and read them back with Recent; the AW-backed adapter persists them to
|
||||||
|
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
|
||||||
|
// them — this package stays a generic event store.
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/aw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one derived event Keel chooses to remember. Kind is the event type
|
||||||
|
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
|
||||||
|
// other tools' source of truth.
|
||||||
|
type Event struct {
|
||||||
|
Kind string
|
||||||
|
At time.Time
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store records derived events and reads recent ones back by kind.
|
||||||
|
type Store interface {
|
||||||
|
Record(ctx context.Context, e Event) error
|
||||||
|
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
|
||||||
|
type nopStore struct{}
|
||||||
|
|
||||||
|
// NewNop returns a Store that drops writes and returns no history.
|
||||||
|
func NewNop() Store { return nopStore{} }
|
||||||
|
|
||||||
|
func (nopStore) Record(context.Context, Event) error { return nil }
|
||||||
|
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
|
||||||
|
|
||||||
|
// Fake is an in-memory Store for tests.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFake returns an empty in-memory Store.
|
||||||
|
func NewFake() *Fake { return &Fake{} }
|
||||||
|
|
||||||
|
func (f *Fake) Record(_ context.Context, e Event) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.events = append(f.events, e)
|
||||||
|
f.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []Event
|
||||||
|
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
|
||||||
|
if f.events[i].Kind == kind {
|
||||||
|
out = append(out, f.events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns a snapshot of everything recorded, for test assertions.
|
||||||
|
func (f *Fake) Events() []Event {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]Event(nil), f.events...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// overfetch bounds how many recent bucket events awStore pulls before filtering
|
||||||
|
// by kind, because keel.events interleaves all kinds. Events older than this
|
||||||
|
// window are not seen — acceptable for "recent".
|
||||||
|
const overfetch = 200
|
||||||
|
|
||||||
|
// kindKey is the data field carrying the Event.Kind inside an AW event.
|
||||||
|
const kindKey = "_kind"
|
||||||
|
|
||||||
|
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
|
||||||
|
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
|
||||||
|
type AWClient interface {
|
||||||
|
Insert(ctx context.Context, bucketID string, e aw.Event) error
|
||||||
|
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type awStore struct {
|
||||||
|
c AWClient
|
||||||
|
bucketID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAWStore returns a Store that persists events to the given AW bucket.
|
||||||
|
func NewAWStore(c AWClient, bucketID string) Store {
|
||||||
|
return &awStore{c: c, bucketID: bucketID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awStore) Record(ctx context.Context, e Event) error {
|
||||||
|
data := make(map[string]any, len(e.Data)+1)
|
||||||
|
for k, v := range e.Data {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data[kindKey] = e.Kind
|
||||||
|
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Event
|
||||||
|
for _, ev := range raw {
|
||||||
|
if len(out) >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if k, _ := ev.Data[kindKey].(string); k == kind {
|
||||||
|
data := make(map[string]any, len(ev.Data))
|
||||||
|
for dk, dv := range ev.Data {
|
||||||
|
if dk != kindKey {
|
||||||
|
data[dk] = dv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: data})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/aw"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
|
||||||
|
|
||||||
|
got, err := f.Recent(ctx, "proposal_made", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Data["n"] != "b" {
|
||||||
|
t.Fatalf("want newest-first [b,a], got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRecentRespectsLimit(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
|
||||||
|
}
|
||||||
|
got, _ := f.Recent(ctx, "proposal_made", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("limit not honored: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNopStoreIsInert(t *testing.T) {
|
||||||
|
s := NewNop()
|
||||||
|
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
|
||||||
|
t.Fatalf("nop Record: %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.Recent(context.Background(), "x", 5)
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("nop Recent = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
|
||||||
|
// recent list so we can test mapping and over-fetch filtering without HTTP.
|
||||||
|
type fakeAWClient struct {
|
||||||
|
inserted []aw.Event
|
||||||
|
recent []aw.Event // newest-first, mixed kinds
|
||||||
|
lastLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
|
||||||
|
f.inserted = append(f.inserted, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
|
||||||
|
f.lastLimit = limit
|
||||||
|
return f.recent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
|
||||||
|
fc := &fakeAWClient{}
|
||||||
|
s := NewAWStore(fc, "keel.events")
|
||||||
|
at := time.Unix(1000, 0).UTC()
|
||||||
|
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
|
||||||
|
t.Fatalf("Record: %v", err)
|
||||||
|
}
|
||||||
|
if len(fc.inserted) != 1 {
|
||||||
|
t.Fatalf("inserted %d", len(fc.inserted))
|
||||||
|
}
|
||||||
|
got := fc.inserted[0]
|
||||||
|
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
|
||||||
|
t.Fatalf("mapped event = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
|
||||||
|
fc := &fakeAWClient{recent: []aw.Event{
|
||||||
|
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
|
||||||
|
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
|
||||||
|
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
|
||||||
|
}}
|
||||||
|
s := NewAWStore(fc, "keel.events")
|
||||||
|
got, err := s.Recent(context.Background(), "proposal_made", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if fc.lastLimit != overfetch {
|
||||||
|
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
|
||||||
|
t.Fatalf("filtered = %+v", got)
|
||||||
|
}
|
||||||
|
if _, leaked := got[0].Data["_kind"]; leaked {
|
||||||
|
t.Fatalf("_kind leaked into returned Data: %+v", got[0].Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecentCapsAtN(t *testing.T) {
|
||||||
|
var recent []aw.Event
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
|
||||||
|
}
|
||||||
|
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
|
||||||
|
got, _ := s.Recent(context.Background(), "proposal_made", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("cap not applied: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package domain holds the core commitment types and validation, ported from
|
// Package domain holds the core commitment types and validation. These are
|
||||||
// the original Rust implementation. These are pure data types with no I/O.
|
// pure data types with no I/O.
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package session
|
package focus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"antidrift/internal/ai"
|
|
||||||
"antidrift/internal/domain"
|
|
||||||
"antidrift/internal/enforce"
|
|
||||||
"antidrift/internal/evidence"
|
|
||||||
"antidrift/internal/store"
|
|
||||||
"context"
|
"context"
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/enforce"
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
"keel/internal/store"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,7 +31,7 @@ const (
|
|||||||
|
|
||||||
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
||||||
// working; unmatched windows simply stay idle.
|
// working; unmatched windows simply stay idle.
|
||||||
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
func (c *Mode) SetDriftJudge(j ai.DriftJudge) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.judge = j
|
c.judge = j
|
||||||
c.mu.Unlock()
|
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
|
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
|
||||||
// enforcement; everything else behaves identically.
|
// enforcement; everything else behaves identically.
|
||||||
func (c *Controller) SetGuard(g enforce.Guard) {
|
func (c *Mode) SetGuard(g enforce.Guard) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.guard = g
|
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;
|
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||||
// local matching and the drift judge are unaffected.
|
// 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.mu.Lock()
|
||||||
c.nudge = n
|
c.nudge = n
|
||||||
c.mu.Unlock()
|
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
|
// commitmentLineLocked renders the active commitment as a single line for AI
|
||||||
// prompts, or "" if there is none. Caller holds mu.
|
// prompts, or "" if there is none. Caller holds mu.
|
||||||
func (c *Controller) commitmentLineLocked() string {
|
func (c *Mode) commitmentLineLocked() string {
|
||||||
if c.commitment == nil {
|
if c.commitment == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -63,29 +63,29 @@ func (c *Controller) commitmentLineLocked() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
||||||
func (c *Controller) recentTitlesForTest() []string {
|
func (c *Mode) recentTitlesForTest() []string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return append([]string(nil), c.recentTitles...)
|
return append([]string(nil), c.recentTitles...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||||
func (c *Controller) resetDriftLocked() {
|
func (c *Mode) resetDriftLocked() {
|
||||||
c.driftStatus = driftIdle
|
c.driftStatus = driftIdle
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
c.driftGen++
|
c.driftGen++
|
||||||
c.nudgeEpoch++
|
c.nudgeEpoch++
|
||||||
c.lastJudgedAt = time.Time{}
|
c.lastJudgedAt = time.Time{}
|
||||||
c.judgedClasses = map[string]ai.Verdict{}
|
c.judgedWindows = map[string]ai.Verdict{}
|
||||||
c.recentTitles = nil
|
c.recentTitles = nil
|
||||||
c.nudgeMessage = ""
|
c.nudgeMessage = ""
|
||||||
c.lastNudgedAt = time.Time{}
|
c.lastNudgedAt = time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnTask appends the current window class to the session allowed-context, clears
|
// OnTask appends the current window class to the session allowed-context, clears
|
||||||
// drift, drops any cached verdict for that class, and persists. The class now
|
// drift, drops the cached verdict for the current window, and persists. The
|
||||||
// matches locally and is never re-judged.
|
// class now matches locally and is never re-judged.
|
||||||
func (c *Controller) OnTask() error {
|
func (c *Mode) OnTask() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.runtimeState != domain.RuntimeActive {
|
if c.runtimeState != domain.RuntimeActive {
|
||||||
@@ -100,7 +100,7 @@ func (c *Controller) OnTask() error {
|
|||||||
if !evidence.MatchesAllowed(ac, class, "") {
|
if !evidence.MatchesAllowed(ac, class, "") {
|
||||||
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
||||||
}
|
}
|
||||||
delete(c.judgedClasses, class)
|
delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title))
|
||||||
}
|
}
|
||||||
c.driftStatus = driftOnTask
|
c.driftStatus = driftOnTask
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
@@ -108,15 +108,15 @@ func (c *Controller) OnTask() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refocus clears the current drift verdict without changing allowed-context, and
|
// Refocus clears the current drift verdict without changing allowed-context, and
|
||||||
// drops the cached verdict for the current class so it may be judged again later.
|
// 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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.runtimeState != domain.RuntimeActive {
|
if c.runtimeState != domain.RuntimeActive {
|
||||||
return ErrNotActive
|
return ErrNotActive
|
||||||
}
|
}
|
||||||
if c.stats != nil {
|
if c.stats != nil {
|
||||||
delete(c.judgedClasses, c.stats.Current.Class)
|
delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title))
|
||||||
}
|
}
|
||||||
c.driftStatus = driftIdle
|
c.driftStatus = driftIdle
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
@@ -126,7 +126,7 @@ func (c *Controller) Refocus() error {
|
|||||||
// RecordWindow ingests one sensor observation. It accumulates time only while
|
// RecordWindow ingests one sensor observation. It accumulates time only while
|
||||||
// Active, always tracks the latest window for display, and fires onChange after
|
// Active, always tracks the latest window for display, and fires onChange after
|
||||||
// releasing the mutex.
|
// releasing the mutex.
|
||||||
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.latestWindow = snap
|
c.latestWindow = snap
|
||||||
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
|
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
|
// 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
|
// 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.
|
// 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 {
|
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func (c *Controller) enforceActionLocked() func() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := guard.MinimizeActive(ctx); err != nil {
|
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
|
// returns the judging closure; the caller runs it in a goroutine after
|
||||||
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
||||||
// already verified the runtime is Active.
|
// 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
|
class, title := snap.Class, snap.Title
|
||||||
|
|
||||||
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
||||||
@@ -193,8 +193,12 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
|||||||
c.nudgeMessage = ""
|
c.nudgeMessage = ""
|
||||||
c.nudgeEpoch++
|
c.nudgeEpoch++
|
||||||
|
|
||||||
// 2. Per-class cache.
|
// 2. Per-window cache, keyed by class+title. A verdict for one window of an
|
||||||
if v, ok := c.judgedClasses[class]; ok {
|
// app must not leak to a sibling window of the same class — a browser hosts
|
||||||
|
// both an on-task reading page and an off-task chat under one window class, so
|
||||||
|
// keying by class alone latched one tab's verdict onto every other tab.
|
||||||
|
key := verdictKey(class, title)
|
||||||
|
if v, ok := c.judgedWindows[key]; ok {
|
||||||
c.applyVerdictLocked(v)
|
c.applyVerdictLocked(v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -237,13 +241,13 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
|||||||
c.driftReason = prevReason
|
c.driftReason = prevReason
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
log.Printf("session: drift judge failed: %v", err)
|
log.Printf("focus: drift judge failed: %v", err)
|
||||||
c.notify()
|
c.notify()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.judgedClasses[class] = v
|
c.judgedWindows[key] = v
|
||||||
var enforceAct func()
|
var enforceAct func()
|
||||||
if c.stats != nil && c.stats.Current.Class == class {
|
if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key {
|
||||||
c.applyVerdictLocked(v)
|
c.applyVerdictLocked(v)
|
||||||
enforceAct = c.enforceActionLocked()
|
enforceAct = c.enforceActionLocked()
|
||||||
}
|
}
|
||||||
@@ -264,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
|
// episode or a session change — is discarded rather than surfacing stale, never
|
||||||
// fabricates a concern on error, and notifies only after unlocking. Caller holds
|
// fabricates a concern on error, and notifies only after unlocking. Caller holds
|
||||||
// mu and has already verified the runtime is Active.
|
// 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 {
|
if c.nudge == nil || len(c.recentTitles) < 2 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -289,7 +293,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.mu.Unlock()
|
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
|
return // never fabricate a concern; leave any prior message intact
|
||||||
}
|
}
|
||||||
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
||||||
@@ -300,7 +304,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
|||||||
|
|
||||||
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
||||||
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
// 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)
|
t := strings.TrimSpace(title)
|
||||||
if t == "" {
|
if t == "" {
|
||||||
return
|
return
|
||||||
@@ -314,8 +318,17 @@ func (c *Controller) recordTitleLocked(title string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verdictKey identifies the judged window for the per-window verdict cache. It
|
||||||
|
// combines the window class with the scrubbed, casefolded title so sibling
|
||||||
|
// windows of one app (a browser's reading tab vs its chat tab) are judged
|
||||||
|
// independently, while transient title noise (clocks, counts) does not split a
|
||||||
|
// window into ever-changing keys that defeat the cache.
|
||||||
|
func verdictKey(class, title string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(class)) + "\x00" + strings.ToLower(evidence.ScrubTitle(title))
|
||||||
|
}
|
||||||
|
|
||||||
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
// 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 {
|
if v.OnTask {
|
||||||
c.driftStatus = driftOnTask
|
c.driftStatus = driftOnTask
|
||||||
c.driftReason = ""
|
c.driftReason = ""
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package focus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"keel/internal/harness"
|
||||||
|
"keel/internal/mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// build constructs a focus Mode under the mode's namespaced dir and injects the
|
||||||
|
// shared services through the existing setters (the same API tests use).
|
||||||
|
func build(svc harness.Services) (*Mode, error) {
|
||||||
|
m, err := New(filepath.Join(svc.Dir, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if svc.Clock != nil {
|
||||||
|
m.SetClock(svc.Clock)
|
||||||
|
}
|
||||||
|
m.SetCoach(svc.AI)
|
||||||
|
m.SetReviewer(svc.AI)
|
||||||
|
m.SetDriftJudge(svc.AI)
|
||||||
|
m.SetNudge(svc.AI)
|
||||||
|
m.SetTasks(svc.Tasks)
|
||||||
|
m.SetKnowledge(svc.Knowledge)
|
||||||
|
m.SetGuard(svc.Enforce)
|
||||||
|
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory builds a fresh focus mode entering Planning (used by harness.Start).
|
||||||
|
func Factory(svc harness.Services) mode.Mode {
|
||||||
|
m, err := build(svc)
|
||||||
|
if err != nil {
|
||||||
|
m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works
|
||||||
|
m.SetOnChange(svc.Notify)
|
||||||
|
}
|
||||||
|
_ = m.EnterPlanning()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore loads a persisted focus session and reports whether one was live
|
||||||
|
// (Planning/Active/Transition/Review). Used at startup to re-adopt a session.
|
||||||
|
func Restore(svc harness.Services) (*Mode, bool, error) {
|
||||||
|
m, err := build(svc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return m, m.Active(), nil
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Package session owns the daemon's in-memory state of truth and persists a
|
// Package focus owns the daemon's in-memory state of truth and persists a
|
||||||
// snapshot on every change. Transitions go through the pure statemachine. It
|
// snapshot on every change. Transitions go through the pure statemachine. It
|
||||||
// also owns per-session evidence stats: it accumulates active-window time while
|
// 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.
|
// Active, logs raw focus events, and seals each session into the audit chain.
|
||||||
package session
|
package focus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -12,14 +12,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
"keel/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"keel/internal/enforce"
|
||||||
"antidrift/internal/enforce"
|
"keel/internal/evidence"
|
||||||
"antidrift/internal/evidence"
|
"keel/internal/harness"
|
||||||
"antidrift/internal/knowledge"
|
"keel/internal/knowledge"
|
||||||
"antidrift/internal/statemachine"
|
"keel/internal/mode/focus/domain"
|
||||||
"antidrift/internal/store"
|
"keel/internal/mode/focus/statemachine"
|
||||||
"antidrift/internal/tasks"
|
"keel/internal/store"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -29,13 +30,14 @@ const (
|
|||||||
sessionRetention = 30 * 24 * time.Hour
|
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.
|
// Mode holds runtime state and the active commitment behind a mutex.
|
||||||
type Controller struct {
|
type Mode struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
async harness.Async
|
||||||
runtimeState domain.RuntimeState
|
runtimeState domain.RuntimeState
|
||||||
commitment *domain.Commitment
|
commitment *domain.Commitment
|
||||||
deadline time.Time
|
deadline time.Time
|
||||||
@@ -43,7 +45,7 @@ type Controller struct {
|
|||||||
auditPath string
|
auditPath string
|
||||||
sessionsDir string
|
sessionsDir string
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
onChange func()
|
onChanges []func()
|
||||||
latestWindow evidence.WindowSnapshot
|
latestWindow evidence.WindowSnapshot
|
||||||
stats *EvidenceStats
|
stats *EvidenceStats
|
||||||
outcomePending string
|
outcomePending string
|
||||||
@@ -80,7 +82,7 @@ type Controller struct {
|
|||||||
driftGen int
|
driftGen int
|
||||||
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
|
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
|
||||||
lastJudgedAt time.Time
|
lastJudgedAt time.Time
|
||||||
judgedClasses map[string]ai.Verdict
|
judgedWindows map[string]ai.Verdict
|
||||||
|
|
||||||
nudge ai.Nudger
|
nudge ai.Nudger
|
||||||
recentTitles []string // in-memory ring of recent distinct titles this session
|
recentTitles []string // in-memory ring of recent distinct titles this session
|
||||||
@@ -90,13 +92,13 @@ type Controller struct {
|
|||||||
|
|
||||||
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
||||||
// in-memory stats from the raw log if a live session was interrupted.
|
// 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)
|
s, err := store.Load(snapshotPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(snapshotPath)
|
dir := filepath.Dir(snapshotPath)
|
||||||
c := &Controller{
|
c := &Mode{
|
||||||
runtimeState: s.RuntimeState,
|
runtimeState: s.RuntimeState,
|
||||||
commitment: s.Commitment,
|
commitment: s.Commitment,
|
||||||
snapshotPath: snapshotPath,
|
snapshotPath: snapshotPath,
|
||||||
@@ -114,12 +116,13 @@ func New(snapshotPath string) (*Controller, error) {
|
|||||||
if c.runtimeState == "" {
|
if c.runtimeState == "" {
|
||||||
c.runtimeState = domain.RuntimeLocked
|
c.runtimeState = domain.RuntimeLocked
|
||||||
}
|
}
|
||||||
|
c.async = harness.NewAsync(&c.mu, c.notify)
|
||||||
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
||||||
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
||||||
c.allowedClasses = s.AllowedWindowClasses
|
c.allowedClasses = s.AllowedWindowClasses
|
||||||
c.enforcementLevel = s.EnforcementLevel
|
c.enforcementLevel = s.EnforcementLevel
|
||||||
// Drift state is not persisted: recompute fresh after restart to avoid
|
// Drift state is not persisted: recompute fresh after restart to avoid
|
||||||
// stale interrupts. This also initializes judgedClasses (the pipeline
|
// stale interrupts. This also initializes judgedWindows (the pipeline
|
||||||
// writes to it) so a restored Active session never panics on a nil map.
|
// writes to it) so a restored Active session never panics on a nil map.
|
||||||
c.resetDriftLocked()
|
c.resetDriftLocked()
|
||||||
c.replayStats(s.SessionID)
|
c.replayStats(s.SessionID)
|
||||||
@@ -129,44 +132,56 @@ func New(snapshotPath string) (*Controller, error) {
|
|||||||
|
|
||||||
// SetClock overrides the time source (tests only). Call before starting a
|
// SetClock overrides the time source (tests only). Call before starting a
|
||||||
// session.
|
// session.
|
||||||
func (c *Controller) SetClock(f func() time.Time) {
|
func (c *Mode) SetClock(f func() time.Time) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.clock = f
|
c.clock = f
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOnChange registers a callback fired after an evidence-driven state change
|
// SetOnChange registers the sole change listener, replacing any already set. A
|
||||||
// (focus updates). It is invoked with the mutex released.
|
// listener is fired after a state change (focus updates, role results) with the
|
||||||
func (c *Controller) SetOnChange(f func()) {
|
// mutex released. Use AddOnChange to register additional listeners.
|
||||||
|
func (c *Mode) SetOnChange(f func()) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.onChange = f
|
c.onChanges = []func(){f}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) notify() {
|
// 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 *Mode) AddOnChange(f func()) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
f := c.onChange
|
c.onChanges = append(c.onChanges, f)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Mode) notify() {
|
||||||
|
c.mu.Lock()
|
||||||
|
fs := append([]func(){}, c.onChanges...)
|
||||||
|
c.mu.Unlock()
|
||||||
|
for _, f := range fs {
|
||||||
if f != nil {
|
if f != nil {
|
||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// State returns the current broadcastable state. Safe for concurrent use.
|
// State returns the current broadcastable state. Safe for concurrent use.
|
||||||
func (c *Controller) State() State {
|
func (c *Mode) State() State {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return c.stateLocked()
|
return c.stateLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deadline returns the active commitment deadline, or the zero time.
|
// 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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return c.deadline
|
return c.deadline
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) persistLocked() error {
|
func (c *Mode) persistLocked() error {
|
||||||
snap := store.Snapshot{
|
snap := store.Snapshot{
|
||||||
RuntimeState: c.runtimeState,
|
RuntimeState: c.runtimeState,
|
||||||
Commitment: c.commitment,
|
Commitment: c.commitment,
|
||||||
@@ -187,7 +202,7 @@ func (c *Controller) persistLocked() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnterPlanning moves Locked -> Planning.
|
// EnterPlanning moves Locked -> Planning.
|
||||||
func (c *Controller) EnterPlanning() error {
|
func (c *Mode) EnterPlanning() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
|
||||||
@@ -202,7 +217,7 @@ func (c *Controller) EnterPlanning() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AllowedClassesForTest exposes the session allowed classes for tests.
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
||||||
func (c *Controller) AllowedClassesForTest() []string {
|
func (c *Mode) AllowedClassesForTest() []string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return append([]string(nil), c.allowedClasses...)
|
return append([]string(nil), c.allowedClasses...)
|
||||||
@@ -210,7 +225,7 @@ func (c *Controller) AllowedClassesForTest() []string {
|
|||||||
|
|
||||||
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
|
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
|
||||||
// only.
|
// only.
|
||||||
func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
|
func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return c.enforcementLevel
|
return c.enforcementLevel
|
||||||
@@ -219,7 +234,7 @@ func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
|
|||||||
// StartManualCommitment validates input, activates a new commitment, mints a
|
// StartManualCommitment validates input, activates a new commitment, mints a
|
||||||
// session, seeds evidence stats from the latest window, and moves Planning ->
|
// session, seeds evidence stats from the latest window, and moves Planning ->
|
||||||
// Active.
|
// 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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
||||||
@@ -259,12 +274,12 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Complete moves Active -> Review with a "completed" outcome.
|
// 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).
|
// 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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
|
||||||
@@ -291,7 +306,7 @@ func (c *Controller) enterReview(outcome string) error {
|
|||||||
|
|
||||||
// End moves Review -> Locked, writes the session summary to the audit chain,
|
// End moves Review -> Locked, writes the session summary to the audit chain,
|
||||||
// and clears the commitment and stats.
|
// and clears the commitment and stats.
|
||||||
func (c *Controller) End() error {
|
func (c *Mode) End() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
|
||||||
@@ -302,7 +317,7 @@ func (c *Controller) End() error {
|
|||||||
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
|
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
|
||||||
// State integrity over audit completeness: the transition still
|
// State integrity over audit completeness: the transition still
|
||||||
// completes. Surfaced for the operator; no auto-retry in M1.
|
// 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
|
c.runtimeState = next
|
||||||
@@ -316,7 +331,7 @@ func (c *Controller) End() error {
|
|||||||
return c.persistLocked()
|
return c.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) buildSummaryLocked() store.SessionSummary {
|
func (c *Mode) buildSummaryLocked() store.SessionSummary {
|
||||||
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
|
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
|
||||||
for k, d := range 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())})
|
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package session
|
package focus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
"keel/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"keel/internal/evidence"
|
||||||
"antidrift/internal/evidence"
|
"keel/internal/knowledge"
|
||||||
"antidrift/internal/knowledge"
|
"keel/internal/mode/focus/domain"
|
||||||
"antidrift/internal/store"
|
"keel/internal/store"
|
||||||
"antidrift/internal/tasks"
|
"keel/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestController(t *testing.T) (*Controller, string) {
|
func newTestController(t *testing.T) (*Mode, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
c, err := New(path)
|
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.
|
// 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()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -386,6 +386,29 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlanningStateExposesCurrentWindowClass(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
st := c.State()
|
||||||
|
if st.Evidence == nil || st.Evidence.Current.Class != "Brave-browser" {
|
||||||
|
t.Fatalf("planning should expose the live window class so the user can pick a matching token, got %+v", st.Evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddOnChangeFansOutToAllListeners(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
var a, b int
|
||||||
|
c.SetOnChange(func() { a++ })
|
||||||
|
c.AddOnChange(func() { b++ })
|
||||||
|
c.notify()
|
||||||
|
if a != 1 || b != 1 {
|
||||||
|
t.Fatalf("every registered listener should fire: a=%d b=%d", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
@@ -419,7 +442,7 @@ func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title str
|
|||||||
return f.verdict, f.err
|
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()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -433,12 +456,12 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State {
|
|||||||
return State{}
|
return State{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startActive(t *testing.T, c *Controller, allowed []string) {
|
func startActive(t *testing.T, c *Mode, allowed []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
startActiveLevel(t, c, allowed, domain.EnforcementWarn)
|
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()
|
t.Helper()
|
||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("planning: %v", err)
|
t.Fatalf("planning: %v", err)
|
||||||
@@ -478,7 +501,54 @@ func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
|
// titleJudge returns a verdict chosen by which substring the title contains,
|
||||||
|
// so a test can model an app (e.g. a browser) hosting both on- and off-task
|
||||||
|
// windows under one window class.
|
||||||
|
type titleJudge struct {
|
||||||
|
byTitle map[string]ai.Verdict
|
||||||
|
calls int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *titleJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||||
|
atomic.AddInt32(&j.calls, 1)
|
||||||
|
for sub, v := range j.byTitle {
|
||||||
|
if strings.Contains(title, sub) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ai.Verdict{OnTask: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brief off-task window must not poison every later same-class window. Teams
|
||||||
|
// and a Consume reading page are both class "Brave-browser"; caching the judge
|
||||||
|
// verdict by class alone made the on-task page inherit Teams's drift verdict
|
||||||
|
// (and never re-judge), latching drift for the rest of the session.
|
||||||
|
func TestVerdictCacheKeysOnTitleNotClassAlone(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
now := time.Now()
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
fj := &titleJudge{byTitle: map[string]ai.Verdict{
|
||||||
|
"Teams": {OnTask: false, Reason: "Teams is off-task"},
|
||||||
|
"Consume": {OnTask: true},
|
||||||
|
}}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"}) // Brave-browser never matches locally
|
||||||
|
|
||||||
|
c.RecordWindow(obs("Brave-browser", "Microsoft Teams - Brave"))
|
||||||
|
waitDriftStatus(t, c, "drifting")
|
||||||
|
|
||||||
|
now = now.Add(2 * driftDebounce) // clear debounce so a re-judge is allowed
|
||||||
|
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
|
||||||
|
st := waitDriftStatus(t, c, "ontask")
|
||||||
|
if st.Drift.Reason != "" {
|
||||||
|
t.Fatalf("on-task window must not carry the Teams reason: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&fj.calls); got != 2 {
|
||||||
|
t.Fatalf("different title under same class should re-judge, calls=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerWindowCacheAvoidsSecondJudge(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
c.SetClock(func() time.Time { return now })
|
c.SetClock(func() time.Time { return now })
|
||||||
@@ -488,12 +558,13 @@ func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
|
|||||||
c.RecordWindow(obs("firefox", "YouTube"))
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
waitDriftStatus(t, c, "drifting")
|
waitDriftStatus(t, c, "drifting")
|
||||||
// Advance past the debounce window so a second judge call would be allowed:
|
// Advance past the debounce window so a second judge call would be allowed:
|
||||||
// this isolates the per-class cache as the only reason the judge is skipped.
|
// this isolates the per-window cache as the only reason the judge is skipped.
|
||||||
now = now.Add(2 * driftDebounce)
|
now = now.Add(2 * driftDebounce)
|
||||||
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
|
c.RecordWindow(obs("Code", "main.go")) // switch away (local on-task match)
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube")) // same window, should hit cache
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
||||||
t.Fatalf("cached class should not re-judge, calls=%d", got)
|
t.Fatalf("cached window should not re-judge, calls=%d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +625,7 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
|||||||
t.Fatalf("reload: %v", err)
|
t.Fatalf("reload: %v", err)
|
||||||
}
|
}
|
||||||
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map
|
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedWindows map
|
||||||
waitDriftStatus(t, second, "drifting")
|
waitDriftStatus(t, second, "drifting")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +752,7 @@ func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, er
|
|||||||
return f(ctx, c, t)
|
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()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -714,8 +785,8 @@ func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
|
|||||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
startActive(t, c, []string{"code"})
|
startActive(t, c, []string{"code"})
|
||||||
c.RecordWindow(obs("firefox", "YouTube"))
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
c.RecordWindow(obs("firefox", "Reddit"))
|
|
||||||
waitDriftStatus(t, c, "drifting")
|
waitDriftStatus(t, c, "drifting")
|
||||||
|
c.RecordWindow(obs("firefox", "Reddit")) // second off-task title builds nudge history
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
||||||
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
||||||
@@ -892,8 +963,10 @@ func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
|||||||
return f.list, f.err
|
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.
|
// 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()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -1115,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.
|
// 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()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -1218,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.
|
// 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()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -1232,7 +1305,7 @@ func waitReflectionStatus(t *testing.T, c *Controller, want string) State {
|
|||||||
return State{}
|
return State{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func driveToReview(t *testing.T, c *Controller) {
|
func driveToReview(t *testing.T, c *Mode) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("planning: %v", err)
|
t.Fatalf("planning: %v", err)
|
||||||
@@ -1484,7 +1557,8 @@ func TestRecordWindowCreditsSplitFaithfully(t *testing.T) {
|
|||||||
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
|
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
|
||||||
|
|
||||||
clk.advance(8 * time.Minute)
|
clk.advance(8 * time.Minute)
|
||||||
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting
|
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task
|
||||||
|
waitDriftStatus(t, c, "drifting") // Reddit is a distinct window: re-judged off-task before its segment is credited
|
||||||
clk.advance(4 * time.Minute)
|
clk.advance(4 * time.Minute)
|
||||||
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
|
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
|
||||||
t.Fatalf("complete: %v", err)
|
t.Fatalf("complete: %v", err)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package focus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/mode"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time assertions: focus.Mode must satisfy the harness contracts.
|
||||||
|
var _ mode.Mode = (*Mode)(nil)
|
||||||
|
var _ mode.EvidenceConsumer = (*Mode)(nil)
|
||||||
|
var _ mode.Expirer = (*Mode)(nil)
|
||||||
|
|
||||||
|
// Kind identifies this mode to the harness.
|
||||||
|
func (c *Mode) Kind() string { return "focus" }
|
||||||
|
|
||||||
|
var errBadPayload = errors.New("focus: invalid command payload")
|
||||||
|
|
||||||
|
type commitmentPayload struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||||||
|
Enforce bool `json:"enforce"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command maps surface command names onto the existing focus transitions.
|
||||||
|
func (c *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
|
||||||
|
switch name {
|
||||||
|
case "planning":
|
||||||
|
return c.EnterPlanning()
|
||||||
|
case "coach":
|
||||||
|
var r struct {
|
||||||
|
Intent string `json:"intent"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &r); err != nil {
|
||||||
|
return errBadPayload
|
||||||
|
}
|
||||||
|
return c.RequestCoach(r.Intent)
|
||||||
|
case "commitment":
|
||||||
|
var r commitmentPayload
|
||||||
|
if err := json.Unmarshal(payload, &r); err != nil {
|
||||||
|
return errBadPayload
|
||||||
|
}
|
||||||
|
level := domain.EnforcementWarn
|
||||||
|
if r.Enforce {
|
||||||
|
level = domain.EnforcementBlock
|
||||||
|
}
|
||||||
|
return c.StartManualCommitment(r.NextAction, r.SuccessCondition,
|
||||||
|
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
|
||||||
|
case "complete":
|
||||||
|
return c.Complete()
|
||||||
|
case "end":
|
||||||
|
return c.End()
|
||||||
|
case "refocus":
|
||||||
|
return c.Refocus()
|
||||||
|
case "ontask":
|
||||||
|
return c.OnTask()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("focus: unknown command %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View returns the focus state projection (the existing State shape).
|
||||||
|
func (c *Mode) View() any { return c.State() }
|
||||||
|
|
||||||
|
// Active is true from Planning through Review. End() drives the runtime back to
|
||||||
|
// Locked, which the harness reads as "done" and releases.
|
||||||
|
func (c *Mode) Active() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.runtimeState != domain.RuntimeLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnWindow forwards the sensor stream to the existing evidence recorder.
|
||||||
|
func (c *Mode) OnWindow(w evidence.WindowSnapshot) { c.RecordWindow(w) }
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package session
|
package focus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"antidrift/internal/ai"
|
|
||||||
"antidrift/internal/domain"
|
|
||||||
"antidrift/internal/knowledge"
|
|
||||||
"antidrift/internal/store"
|
|
||||||
"antidrift/internal/tasks"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
"keel/internal/store"
|
||||||
|
"keel/internal/tasks"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -58,7 +58,7 @@ const (
|
|||||||
|
|
||||||
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
||||||
// RequestCoach degrade gracefully.
|
// RequestCoach degrade gracefully.
|
||||||
func (c *Controller) SetCoach(coach ai.Coach) {
|
func (c *Mode) SetCoach(coach ai.Coach) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.coach = coach
|
c.coach = coach
|
||||||
c.mu.Unlock()
|
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
|
// resetCoachLocked returns coach state to idle and invalidates any in-flight
|
||||||
// request. Caller holds mu.
|
// request. Caller holds mu.
|
||||||
func (c *Controller) resetCoachLocked() {
|
func (c *Mode) resetCoachLocked() {
|
||||||
c.coachStatus = coachIdle
|
c.coachStatus = coachIdle
|
||||||
c.coachProposal = nil
|
c.coachProposal = nil
|
||||||
c.coachErr = ""
|
c.coachErr = ""
|
||||||
@@ -75,47 +75,16 @@ func (c *Controller) resetCoachLocked() {
|
|||||||
|
|
||||||
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
|
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
|
||||||
// the planning tasks list absent.
|
// the planning tasks list absent.
|
||||||
func (c *Controller) SetTasks(p tasks.Provider) {
|
func (c *Mode) SetTasks(p tasks.Provider) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.tasksProvider = p
|
c.tasksProvider = p
|
||||||
c.mu.Unlock()
|
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
|
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
|
||||||
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
|
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
|
||||||
// post-planning results, and notifies on completion. Caller holds mu.
|
// post-planning results, and notifies on completion. Caller holds mu.
|
||||||
func (c *Controller) startTasksFetchLocked() {
|
func (c *Mode) startTasksFetchLocked() {
|
||||||
c.tasksList = nil
|
c.tasksList = nil
|
||||||
if c.tasksProvider == nil {
|
if c.tasksProvider == nil {
|
||||||
c.tasksStatus = tasksIdle
|
c.tasksStatus = tasksIdle
|
||||||
@@ -127,7 +96,7 @@ func (c *Controller) startTasksFetchLocked() {
|
|||||||
provider := c.tasksProvider
|
provider := c.tasksProvider
|
||||||
var list []tasks.Task
|
var list []tasks.Task
|
||||||
var err error
|
var err error
|
||||||
c.runFetchAsync(tasksTimeout,
|
c.async.Run(tasksTimeout,
|
||||||
func(ctx context.Context) { list, err = provider.Today(ctx) },
|
func(ctx context.Context) { list, err = provider.Today(ctx) },
|
||||||
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
|
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
|
||||||
func() {
|
func() {
|
||||||
@@ -143,7 +112,7 @@ func (c *Controller) startTasksFetchLocked() {
|
|||||||
|
|
||||||
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
|
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
|
||||||
// keeps the planning knowledge view absent and the coach ungrounded.
|
// 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.mu.Lock()
|
||||||
c.knowledgeSrc = s
|
c.knowledgeSrc = s
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -152,7 +121,7 @@ func (c *Controller) SetKnowledge(s knowledge.Source) {
|
|||||||
// SetKnowledgePath selects an explicit profile path (session-only; not
|
// SetKnowledgePath selects an explicit profile path (session-only; not
|
||||||
// persisted). While planning, it re-loads immediately so the indicator and the
|
// persisted). While planning, it re-loads immediately so the indicator and the
|
||||||
// cached grounding update. An empty path resets to the adapter default.
|
// 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.mu.Lock()
|
||||||
c.knowledgePath = strings.TrimSpace(path)
|
c.knowledgePath = strings.TrimSpace(path)
|
||||||
planning := c.runtimeState == domain.RuntimePlanning
|
planning := c.runtimeState == domain.RuntimePlanning
|
||||||
@@ -169,7 +138,7 @@ func (c *Controller) SetKnowledgePath(path string) {
|
|||||||
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
|
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
|
||||||
// post-planning results, and notifies on completion. The loaded text is cached
|
// post-planning results, and notifies on completion. The loaded text is cached
|
||||||
// in knowledgeText for the coach to read. Caller holds mu.
|
// in knowledgeText for the coach to read. Caller holds mu.
|
||||||
func (c *Controller) startKnowledgeFetchLocked() {
|
func (c *Mode) startKnowledgeFetchLocked() {
|
||||||
c.knowledgeText = ""
|
c.knowledgeText = ""
|
||||||
c.knowledgeChars = 0
|
c.knowledgeChars = 0
|
||||||
if c.knowledgeSrc == nil {
|
if c.knowledgeSrc == nil {
|
||||||
@@ -183,7 +152,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
|
|||||||
path := c.knowledgePath
|
path := c.knowledgePath
|
||||||
var prof knowledge.Profile
|
var prof knowledge.Profile
|
||||||
var err error
|
var err error
|
||||||
c.runFetchAsync(knowledgeTimeout,
|
c.async.Run(knowledgeTimeout,
|
||||||
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
|
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
|
||||||
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
|
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
|
||||||
func() {
|
func() {
|
||||||
@@ -210,7 +179,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
|
|||||||
|
|
||||||
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
|
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
|
||||||
// leaves the coach ungrounded by any carry-forward.
|
// 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.mu.Lock()
|
||||||
c.reviewer = r
|
c.reviewer = r
|
||||||
c.mu.Unlock()
|
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.
|
// 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
|
// 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.
|
// never leaves stale data from the previous session. Caller holds mu.
|
||||||
func (c *Controller) startReflectionFetchLocked() {
|
func (c *Mode) startReflectionFetchLocked() {
|
||||||
c.reflectionRecap = ""
|
c.reflectionRecap = ""
|
||||||
c.carryForward = ""
|
c.carryForward = ""
|
||||||
if c.reviewer == nil {
|
if c.reviewer == nil {
|
||||||
@@ -245,7 +214,7 @@ func (c *Controller) startReflectionFetchLocked() {
|
|||||||
history := buildReflectionHistory(c.auditPath)
|
history := buildReflectionHistory(c.auditPath)
|
||||||
var refl ai.Reflection
|
var refl ai.Reflection
|
||||||
var err error
|
var err error
|
||||||
c.runFetchAsync(reflectionTimeout,
|
c.async.Run(reflectionTimeout,
|
||||||
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
|
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
|
||||||
func() bool { return gen != c.reflectionGen },
|
func() bool { return gen != c.reflectionGen },
|
||||||
func() {
|
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
|
// 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
|
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
|
||||||
// set (End clears them, but enterReview runs before End).
|
// set (End clears them, but enterReview runs before End).
|
||||||
func (c *Controller) buildReflectionFinishedLocked() string {
|
func (c *Mode) buildReflectionFinishedLocked() string {
|
||||||
var na, sc string
|
var na, sc string
|
||||||
if c.commitment != nil {
|
if c.commitment != nil {
|
||||||
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
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
|
// composedGroundingLocked combines the standing profile (knowledge port) with
|
||||||
// the latest carry-forward takeaway into the single free-form grounding string
|
// the latest carry-forward takeaway into the single free-form grounding string
|
||||||
// the coach already accepts. Caller holds mu.
|
// the coach already accepts. Caller holds mu.
|
||||||
func (c *Controller) composedGroundingLocked() string {
|
func (c *Mode) composedGroundingLocked() string {
|
||||||
g := c.knowledgeText
|
g := c.knowledgeText
|
||||||
if c.carryForward != "" {
|
if c.carryForward != "" {
|
||||||
if g != "" {
|
if g != "" {
|
||||||
@@ -354,7 +323,7 @@ func (c *Controller) composedGroundingLocked() string {
|
|||||||
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
||||||
// not in planning; otherwise never a hard error (failures surface as coach
|
// not in planning; otherwise never a hard error (failures surface as coach
|
||||||
// state). The proposal is ephemeral and never persisted.
|
// 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()
|
c.mu.Lock()
|
||||||
if c.runtimeState != domain.RuntimePlanning {
|
if c.runtimeState != domain.RuntimePlanning {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -380,7 +349,7 @@ func (c *Controller) RequestCoach(intent string) error {
|
|||||||
|
|
||||||
var prop ai.Proposal
|
var prop ai.Proposal
|
||||||
var err error
|
var err error
|
||||||
c.runFetchAsync(coachTimeout,
|
c.async.Run(coachTimeout,
|
||||||
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
|
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
|
||||||
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
|
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
|
||||||
func() {
|
func() {
|
||||||
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
// Package statemachine holds the pure runtime and commitment transition
|
// Package statemachine holds the pure runtime and commitment transition
|
||||||
// functions ported from the Rust implementation. No I/O, no shared state.
|
// functions. No I/O, no shared state.
|
||||||
package statemachine
|
package statemachine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
|
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
|
||||||
// and admin-override exit targets from the Rust enum are flattened into
|
// and admin-override exit targets are flattened into distinct actions so the
|
||||||
// distinct actions so the action type stays a simple value.
|
// action type stays a simple value.
|
||||||
type RuntimeAction string
|
type RuntimeAction string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
+1
-1
@@ -3,7 +3,7 @@ package statemachine
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
|
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package session
|
package focus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"antidrift/internal/evidence"
|
"keel/internal/evidence"
|
||||||
"antidrift/internal/store"
|
"keel/internal/store"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ type EvidenceStats struct {
|
|||||||
// applyEvent advances stats by one observation: it credits the prior segment to
|
// 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
|
// 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.
|
// 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 {
|
if c.stats.hasLast {
|
||||||
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
|
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
|
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
|
||||||
// before the state transition). idle/pending route to unclassified: honest, never
|
// before the state transition). idle/pending route to unclassified: honest, never
|
||||||
// falsely on-task. Caller holds mu.
|
// 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
|
c.stats.Buckets[k] += d
|
||||||
switch c.driftStatus {
|
switch c.driftStatus {
|
||||||
case driftOnTask:
|
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.
|
// 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)
|
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
||||||
if err != nil || len(events) == 0 {
|
if err != nil || len(events) == 0 {
|
||||||
c.stats = &EvidenceStats{
|
c.stats = &EvidenceStats{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package session
|
package focus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -102,7 +102,7 @@ type State struct {
|
|||||||
Drift *DriftView `json:"drift,omitempty"`
|
Drift *DriftView `json:"drift,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) stateLocked() State {
|
func (c *Mode) stateLocked() State {
|
||||||
st := State{RuntimeState: c.runtimeState}
|
st := State{RuntimeState: c.runtimeState}
|
||||||
if c.commitment != nil {
|
if c.commitment != nil {
|
||||||
view := &CommitmentView{
|
view := &CommitmentView{
|
||||||
@@ -125,6 +125,14 @@ func (c *Controller) stateLocked() State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.runtimeState == domain.RuntimePlanning {
|
if c.runtimeState == domain.RuntimePlanning {
|
||||||
|
// Surface the live window so planning can show the real class to copy into
|
||||||
|
// the allowed-apps field — exact class names are otherwise invisible, and a
|
||||||
|
// non-matching token silently disables the local on-task fast path.
|
||||||
|
st.Evidence = &EvidenceView{
|
||||||
|
Available: c.latestWindow.Health.Available,
|
||||||
|
Reason: c.latestWindow.Health.Reason,
|
||||||
|
Current: WindowView{Class: c.latestWindow.Class, Title: c.latestWindow.Title},
|
||||||
|
}
|
||||||
status := c.coachStatus
|
status := c.coachStatus
|
||||||
if status == "" {
|
if status == "" {
|
||||||
status = coachIdle
|
status = coachIdle
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Package mode defines the contract the harness uses to host a single
|
||||||
|
// collect→brain→act unit. Focus and off-screen are implementations.
|
||||||
|
package mode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode is one collect→brain→act configuration. The harness runs at most one.
|
||||||
|
type Mode interface {
|
||||||
|
// Kind identifies the mode ("focus", "offscreen").
|
||||||
|
Kind() string
|
||||||
|
// Command routes a surface-issued command into the mode. It returns an
|
||||||
|
// error for illegal/invalid commands; the web layer maps it to HTTP.
|
||||||
|
Command(ctx context.Context, name string, payload json.RawMessage) error
|
||||||
|
// View projects the mode's current state for surfaces (JSON-marshalable).
|
||||||
|
View() any
|
||||||
|
// Active reports whether the mode still holds the harness. When it returns
|
||||||
|
// false the harness releases the mode and goes idle.
|
||||||
|
Active() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvidenceConsumer is implemented by modes that read the window sensor stream.
|
||||||
|
type EvidenceConsumer interface {
|
||||||
|
OnWindow(evidence.WindowSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expirer is implemented by modes with a server-authoritative deadline. Expire
|
||||||
|
// takes no context to match focus's existing Expire() error method, so focus
|
||||||
|
// satisfies this interface with zero changes to its tested surface.
|
||||||
|
type Expirer interface {
|
||||||
|
Deadline() time.Time
|
||||||
|
Expire() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope is the surfacing wrapper: the active mode's kind plus its View().
|
||||||
|
type Envelope struct {
|
||||||
|
ActiveMode string `json:"active_mode"` // "" = idle
|
||||||
|
Mode any `json:"mode,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// internal/mode/offscreen/collect.go
|
||||||
|
package offscreen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/frame"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
recentProposals = 5 // how many recent proposals to show
|
||||||
|
outcomeScan = 50 // how many outcome events to scan for correlation
|
||||||
|
recentWindow = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// assembleBrief builds the off-screen prompt context: the shared frame brief
|
||||||
|
// (today's tasks + ~/owc goals and life-domains) plus off-screen's own recent
|
||||||
|
// proposal history. Best-effort: never panics, never errors, tolerates nil
|
||||||
|
// ports and missing files.
|
||||||
|
func (m *Mode) assembleBrief() string {
|
||||||
|
base := frame.Assemble(context.Background(), m.know, m.tasks)
|
||||||
|
|
||||||
|
hist := m.briefHistory()
|
||||||
|
if hist == "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(base)
|
||||||
|
if !strings.HasSuffix(base, "\n") {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n## Recently proposed\n")
|
||||||
|
b.WriteString(hist)
|
||||||
|
return frame.Truncate(b.String(), frame.MaxBriefBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefHistory renders recent proposals with their outcome, newest first and
|
||||||
|
// within recentWindow. Returns "" when there is no memory or no recent history.
|
||||||
|
func (m *Mode) briefHistory() string {
|
||||||
|
if m.mem == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals)
|
||||||
|
if err != nil || len(proposals) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
taken := m.idSet(ctx, kindActionTaken)
|
||||||
|
dismissed := m.idSet(ctx, kindProposalDismissed)
|
||||||
|
now := m.now()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ev := range proposals {
|
||||||
|
if now.Sub(ev.At) > recentWindow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := ev.Data["proposal_id"].(string)
|
||||||
|
action, _ := ev.Data["next_action"].(string)
|
||||||
|
if action == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outcome := "unactioned"
|
||||||
|
if taken[id] {
|
||||||
|
outcome = "confirmed"
|
||||||
|
} else if dismissed[id] {
|
||||||
|
outcome = "dismissed"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At)))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// idSet reads recent events of a kind and returns their proposal_ids as a set.
|
||||||
|
func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
evs, err := m.mem.Recent(ctx, kind, outcomeScan)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, e := range evs {
|
||||||
|
if id, _ := e.Data["proposal_id"].(string); id != "" {
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// relativeAge renders a coarse human age for a duration.
|
||||||
|
func relativeAge(d time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
|
||||||
|
// off-screen action from the life-domain frame and files it on confirm. It is
|
||||||
|
// one-shot — after confirm or dismiss it goes inactive and the harness releases
|
||||||
|
// it.
|
||||||
|
package offscreen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/harness"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
|
"keel/internal/mode"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
const proposeTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
statusPending = "pending"
|
||||||
|
statusProposed = "proposed"
|
||||||
|
statusError = "error"
|
||||||
|
statusDone = "done"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kindProposalMade = "proposal_made"
|
||||||
|
kindActionTaken = "action_taken"
|
||||||
|
kindProposalDismissed = "proposal_dismissed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
||||||
|
type Mode struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
async harness.Async
|
||||||
|
ai *ai.Service
|
||||||
|
tasks tasks.Provider
|
||||||
|
know knowledge.Source // may be nil; assembleBrief must guard
|
||||||
|
mem memory.Store // may be nil; record helpers guard
|
||||||
|
now func() time.Time // event timestamps; never nil after New
|
||||||
|
|
||||||
|
status string
|
||||||
|
gen int
|
||||||
|
proposal *ai.OffscreenProposal
|
||||||
|
errMsg string
|
||||||
|
done bool
|
||||||
|
proposalID string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ mode.Mode = (*Mode)(nil)
|
||||||
|
|
||||||
|
// New builds an off-screen mode from the shared services. It does not start the
|
||||||
|
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
|
||||||
|
func New(svc harness.Services) *Mode {
|
||||||
|
now := svc.Clock
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
m := &Mode{
|
||||||
|
ai: svc.AI,
|
||||||
|
tasks: svc.Tasks,
|
||||||
|
know: svc.Knowledge,
|
||||||
|
mem: svc.Memory,
|
||||||
|
now: now,
|
||||||
|
status: statusPending,
|
||||||
|
}
|
||||||
|
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mode) Kind() string { return "offscreen" }
|
||||||
|
|
||||||
|
// Active reports whether the mode still holds the harness. It goes false after
|
||||||
|
// confirm or dismiss.
|
||||||
|
func (m *Mode) Active() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return !m.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command routes a surface command. start (re)runs the brief; confirm files the
|
||||||
|
// proposed action and finishes; dismiss finishes without writing.
|
||||||
|
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
|
||||||
|
switch name {
|
||||||
|
case "start":
|
||||||
|
return m.start()
|
||||||
|
case "confirm":
|
||||||
|
return m.confirm(ctx)
|
||||||
|
case "dismiss":
|
||||||
|
m.mu.Lock()
|
||||||
|
id := m.proposalID
|
||||||
|
m.done = true
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.recordSync(memory.Event{
|
||||||
|
Kind: kindProposalDismissed,
|
||||||
|
At: m.now(),
|
||||||
|
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("offscreen: unknown command %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start assembles the brief and kicks off the brain asynchronously. A new call
|
||||||
|
// bumps the generation so a stale in-flight result is discarded.
|
||||||
|
func (m *Mode) start() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.gen++
|
||||||
|
gen := m.gen
|
||||||
|
m.status = statusPending
|
||||||
|
m.proposal = nil
|
||||||
|
m.errMsg = ""
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
brief := m.assembleBrief()
|
||||||
|
|
||||||
|
var p ai.OffscreenProposal
|
||||||
|
var err error
|
||||||
|
m.async.Run(proposeTimeout,
|
||||||
|
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
|
||||||
|
func() bool { return gen != m.gen },
|
||||||
|
func() {
|
||||||
|
if err != nil {
|
||||||
|
m.status = statusError
|
||||||
|
m.errMsg = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.status = statusProposed
|
||||||
|
m.proposal = &p
|
||||||
|
id := newProposalID()
|
||||||
|
m.proposalID = id
|
||||||
|
m.recordAsync(memory.Event{
|
||||||
|
Kind: kindProposalMade,
|
||||||
|
At: m.now(),
|
||||||
|
Data: map[string]any{
|
||||||
|
"proposal_id": id,
|
||||||
|
"mode": "offscreen",
|
||||||
|
"next_action": p.NextAction,
|
||||||
|
"rationale": p.Rationale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm files the proposed action as a task and ends the mode. It errors if
|
||||||
|
// there is nothing to confirm yet.
|
||||||
|
func (m *Mode) confirm(ctx context.Context) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
p := m.proposal
|
||||||
|
m.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return errors.New("offscreen: nothing to confirm")
|
||||||
|
}
|
||||||
|
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
id := m.proposalID
|
||||||
|
m.status = statusDone
|
||||||
|
m.done = true
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.recordSync(memory.Event{
|
||||||
|
Kind: kindActionTaken,
|
||||||
|
At: m.now(),
|
||||||
|
Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View projects the mode's state for surfaces. Always JSON-marshalable.
|
||||||
|
func (m *Mode) View() any {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
v := map[string]any{"status": m.status}
|
||||||
|
if m.proposal != nil {
|
||||||
|
v["next_action"] = m.proposal.NextAction
|
||||||
|
v["rationale"] = m.proposal.Rationale
|
||||||
|
}
|
||||||
|
if m.errMsg != "" {
|
||||||
|
v["error"] = m.errMsg
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
|
||||||
|
func Factory(svc harness.Services) mode.Mode {
|
||||||
|
m := New(svc)
|
||||||
|
_ = m.start()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAsync persists an event off the mode's lock, best-effort. Used on the
|
||||||
|
// propose path, which runs under the mode mutex (async apply).
|
||||||
|
func (m *Mode) recordAsync(ev memory.Event) {
|
||||||
|
if m.mem == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := m.mem.Record(context.Background(), ev); err != nil {
|
||||||
|
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSync persists an event inline, best-effort. Used on confirm/dismiss,
|
||||||
|
// which already do synchronous work.
|
||||||
|
func (m *Mode) recordSync(ev memory.Event) {
|
||||||
|
if m.mem == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.mem.Record(context.Background(), ev); err != nil {
|
||||||
|
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProposalID returns a short random hex id correlating a proposal with its
|
||||||
|
// outcome event.
|
||||||
|
func newProposalID() string {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "proposal"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package offscreen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/harness"
|
||||||
|
"keel/internal/memory"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubBackend is a local fake ai.Backend; ai's package-internal fake is not
|
||||||
|
// reachable from this package, so we define our own here.
|
||||||
|
type stubBackend struct {
|
||||||
|
out string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b stubBackend) Name() string { return "stub" }
|
||||||
|
func (b stubBackend) Run(context.Context, string) (string, error) {
|
||||||
|
return b.out, b.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTasks struct {
|
||||||
|
created []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil }
|
||||||
|
func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error {
|
||||||
|
f.created = append(f.created, t.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestMode(t *testing.T, ft *fakeTasks, backend ai.Backend) *Mode {
|
||||||
|
t.Helper()
|
||||||
|
svc := harness.Services{
|
||||||
|
AI: ai.NewService(backend),
|
||||||
|
Tasks: ft,
|
||||||
|
Clock: func() time.Time { return time.Unix(0, 0) },
|
||||||
|
Notify: func() {},
|
||||||
|
}
|
||||||
|
return New(svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitStatus polls the mode's View until status reaches want, or fails after 2s.
|
||||||
|
func waitStatus(t *testing.T, m *Mode, want string) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
v, _ := m.View().(map[string]any)
|
||||||
|
if v != nil && v["status"] == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("status never reached %q (last view: %+v)", want, m.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
func okBackend() stubBackend {
|
||||||
|
return stubBackend{out: `{"next_action":"call mum","rationale":"people goal"}`}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmFilesExactlyOneTask(t *testing.T) {
|
||||||
|
ft := &fakeTasks{}
|
||||||
|
m := newTestMode(t, ft, okBackend())
|
||||||
|
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
|
||||||
|
if err := m.Command(context.Background(), "confirm", nil); err != nil {
|
||||||
|
t.Fatalf("confirm: %v", err)
|
||||||
|
}
|
||||||
|
if len(ft.created) != 1 {
|
||||||
|
t.Fatalf("created %d tasks, want 1", len(ft.created))
|
||||||
|
}
|
||||||
|
if ft.created[0] != "call mum" {
|
||||||
|
t.Fatalf("created task title = %q, want %q", ft.created[0], "call mum")
|
||||||
|
}
|
||||||
|
if m.Active() {
|
||||||
|
t.Fatal("mode should be done after confirm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissWritesNothing(t *testing.T) {
|
||||||
|
ft := &fakeTasks{}
|
||||||
|
m := newTestMode(t, ft, okBackend())
|
||||||
|
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
|
||||||
|
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
|
||||||
|
t.Fatalf("dismiss: %v", err)
|
||||||
|
}
|
||||||
|
if len(ft.created) != 0 {
|
||||||
|
t.Fatalf("dismiss created %d tasks, want 0", len(ft.created))
|
||||||
|
}
|
||||||
|
if m.Active() {
|
||||||
|
t.Fatal("mode should be done after dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeErrorSurfacesInView(t *testing.T) {
|
||||||
|
ft := &fakeTasks{}
|
||||||
|
m := newTestMode(t, ft, stubBackend{err: errors.New("boom")})
|
||||||
|
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
waitStatus(t, m, statusError)
|
||||||
|
|
||||||
|
v, _ := m.View().(map[string]any)
|
||||||
|
if v == nil || v["error"] == nil || v["error"] == "" {
|
||||||
|
t.Fatalf("expected error field in view, got %+v", v)
|
||||||
|
}
|
||||||
|
if !m.Active() {
|
||||||
|
t.Fatal("mode should stay active on error so the user can retry or dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmWithoutProposalErrors(t *testing.T) {
|
||||||
|
ft := &fakeTasks{}
|
||||||
|
m := newTestMode(t, ft, okBackend())
|
||||||
|
// No start → no proposal.
|
||||||
|
if err := m.Command(context.Background(), "confirm", nil); err == nil {
|
||||||
|
t.Fatal("confirm without a proposal should error")
|
||||||
|
}
|
||||||
|
if len(ft.created) != 0 {
|
||||||
|
t.Fatalf("confirm without proposal created %d tasks, want 0", len(ft.created))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownCommandErrors(t *testing.T) {
|
||||||
|
m := newTestMode(t, &fakeTasks{}, okBackend())
|
||||||
|
if err := m.Command(context.Background(), "bogus", nil); err == nil {
|
||||||
|
t.Fatal("unknown command should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestModeMem is newTestMode with an injected in-memory Store.
|
||||||
|
func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode {
|
||||||
|
t.Helper()
|
||||||
|
svc := harness.Services{
|
||||||
|
AI: ai.NewService(backend),
|
||||||
|
Tasks: ft,
|
||||||
|
Memory: mem,
|
||||||
|
Clock: func() time.Time { return time.Unix(1000, 0) },
|
||||||
|
Notify: func() {},
|
||||||
|
}
|
||||||
|
return New(svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitEvent polls the fake until an event of kind appears, or fails after 2s.
|
||||||
|
func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
for _, e := range f.Events() {
|
||||||
|
if e.Kind == kind {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events())
|
||||||
|
return memory.Event{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeRecordsProposalMade(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
if err := m.Command(context.Background(), "start", nil); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
|
||||||
|
ev := waitEvent(t, mem, "proposal_made")
|
||||||
|
if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" {
|
||||||
|
t.Fatalf("proposal_made data = %+v", ev.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmRecordsActionTakenWithSameID(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
_ = m.Command(context.Background(), "start", nil)
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
made := waitEvent(t, mem, "proposal_made")
|
||||||
|
|
||||||
|
if err := m.Command(context.Background(), "confirm", nil); err != nil {
|
||||||
|
t.Fatalf("confirm: %v", err)
|
||||||
|
}
|
||||||
|
taken := waitEvent(t, mem, "action_taken")
|
||||||
|
if taken.Data["proposal_id"] != made.Data["proposal_id"] {
|
||||||
|
t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissRecordsProposalDismissed(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
_ = m.Command(context.Background(), "start", nil)
|
||||||
|
waitStatus(t, m, statusProposed)
|
||||||
|
_ = waitEvent(t, mem, "proposal_made")
|
||||||
|
|
||||||
|
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
|
||||||
|
t.Fatalf("dismiss: %v", err)
|
||||||
|
}
|
||||||
|
_ = waitEvent(t, mem, "proposal_dismissed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) {
|
||||||
|
mem := memory.NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
// at = the test clock (Unix 1000); confirmed walk, dismissed stretch.
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "w", "next_action": "walk"}})
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "w"}})
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}})
|
||||||
|
_ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0),
|
||||||
|
Data: map[string]any{"proposal_id": "s"}})
|
||||||
|
|
||||||
|
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
|
||||||
|
brief := m.assembleBrief()
|
||||||
|
|
||||||
|
if !strings.Contains(brief, "Recently proposed") {
|
||||||
|
t.Fatalf("brief missing history section:\n%s", brief)
|
||||||
|
}
|
||||||
|
if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") {
|
||||||
|
t.Fatalf("brief missing confirmed walk:\n%s", brief)
|
||||||
|
}
|
||||||
|
if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") {
|
||||||
|
t.Fatalf("brief missing dismissed stretch:\n%s", brief)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) {
|
||||||
|
m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected
|
||||||
|
if strings.Contains(m.assembleBrief(), "Recently proposed") {
|
||||||
|
t.Fatal("history section should be absent without memory")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// internal/notify/notify.go
|
||||||
|
|
||||||
|
// Package notify is Keel's desktop-notification effector: a best-effort
|
||||||
|
// "interrupt the user" primitive behind a Notifier port. On Linux with
|
||||||
|
// notify-send installed it pops a toast; everywhere else it is a silent nop.
|
||||||
|
// Mirrors internal/enforce: a pure OS primitive, all policy lives in callers.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifier shows a desktop notification. Best-effort: it returns an error for
|
||||||
|
// diagnostics, but callers never block on it and treat failure as "no toast."
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, title, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotifier returns the notify-send adapter when that binary is on PATH, else
|
||||||
|
// a nop. This makes a missing binary (or a non-Linux host) degrade to silence
|
||||||
|
// rather than an error.
|
||||||
|
func NewNotifier() Notifier {
|
||||||
|
if path, err := exec.LookPath("notify-send"); err == nil {
|
||||||
|
return sendNotifier{bin: path}
|
||||||
|
}
|
||||||
|
return nopNotifier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendNotifier shells out to notify-send. The app name groups Keel's toasts.
|
||||||
|
type sendNotifier struct{ bin string }
|
||||||
|
|
||||||
|
func (s sendNotifier) Notify(ctx context.Context, title, body string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, s.bin, "--app-name=Keel", title, body)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("notify: notify-send: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nopNotifier is the silent fallback.
|
||||||
|
type nopNotifier struct{}
|
||||||
|
|
||||||
|
func (nopNotifier) Notify(context.Context, string, string) error { return nil }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// internal/notify/notify_test.go
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nopNotifier must be returned when notify-send is absent, and must never error.
|
||||||
|
func TestNopNotifierIsSilentAndSafe(t *testing.T) {
|
||||||
|
n := nopNotifier{}
|
||||||
|
if err := n.Notify(context.Background(), "title", "body"); err != nil {
|
||||||
|
t.Fatalf("nop Notify must return nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotifier always returns a usable Notifier (never nil), whatever the host.
|
||||||
|
func TestNewNotifierNeverNil(t *testing.T) {
|
||||||
|
if NewNotifier() == nil {
|
||||||
|
t.Fatal("NewNotifier returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package settings persists the daemon's user-editable configuration as a single
|
// 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
|
// leaf type package: it imports nothing else in the app so any layer may depend
|
||||||
// on the Settings value without pulling in adapters.
|
// on the Settings value without pulling in adapters.
|
||||||
package settings
|
package settings
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
||||||
@@ -16,21 +17,46 @@ import (
|
|||||||
// without importing the ai package.
|
// without importing the ai package.
|
||||||
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
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
|
// 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 {
|
type Settings struct {
|
||||||
AIBackend string `json:"ai_backend"`
|
AIBackend string `json:"ai_backend"`
|
||||||
MarvinCmd string `json:"marvin_cmd"`
|
MarvinCmd string `json:"marvin_cmd"`
|
||||||
KnowledgePath string `json:"knowledge_path"`
|
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) {
|
func DefaultPath() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
// 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)
|
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
|
// the same defaults the daemon used before the settings file existed. An unset
|
||||||
// AI backend defaults to "claude" (matching ai.NewBackend("")).
|
// AI backend defaults to "claude" (matching ai.NewBackend("")).
|
||||||
func SeedFromEnv() Settings {
|
func SeedFromEnv() Settings {
|
||||||
backend := os.Getenv("ANTIDRIFT_AI_BACKEND")
|
backend := os.Getenv("KEEL_AI_BACKEND")
|
||||||
if backend == "" {
|
if backend == "" {
|
||||||
backend = "claude"
|
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{
|
return Settings{
|
||||||
AIBackend: backend,
|
AIBackend: backend,
|
||||||
MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"),
|
MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
|
||||||
KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"),
|
KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
|
||||||
|
AWURL: awURL,
|
||||||
|
AmbientMode: ambientMode,
|
||||||
|
AmbientCadenceSecs: ambientCadenceSecs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "settings.json")
|
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 {
|
if err := Save(path, want); err != nil {
|
||||||
t.Fatalf("save: %v", err)
|
t.Fatalf("save: %v", err)
|
||||||
}
|
}
|
||||||
@@ -29,20 +29,23 @@ func TestLoadMissingFileIsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSeedFromEnvReadsVars(t *testing.T) {
|
func TestSeedFromEnvReadsVars(t *testing.T) {
|
||||||
t.Setenv("ANTIDRIFT_AI_BACKEND", "codex")
|
t.Setenv("KEEL_AI_BACKEND", "codex")
|
||||||
t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am")
|
t.Setenv("KEEL_MARVIN_CMD", "uv run am")
|
||||||
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md")
|
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()
|
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 {
|
if got != want {
|
||||||
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSeedFromEnvDefaultsBackend(t *testing.T) {
|
func TestSeedFromEnvDefaultsBackend(t *testing.T) {
|
||||||
t.Setenv("ANTIDRIFT_AI_BACKEND", "")
|
t.Setenv("KEEL_AI_BACKEND", "")
|
||||||
t.Setenv("ANTIDRIFT_MARVIN_CMD", "")
|
t.Setenv("KEEL_MARVIN_CMD", "")
|
||||||
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "")
|
t.Setenv("KEEL_KNOWLEDGE_FILE", "")
|
||||||
got := SeedFromEnv()
|
got := SeedFromEnv()
|
||||||
if got.AIBackend != "claude" {
|
if got.AIBackend != "claude" {
|
||||||
t.Errorf("default backend = %q, want claude", got.AIBackend)
|
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)
|
t.Fatalf("file not written: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAWURL(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AW_URL", "http://aw.example:9999")
|
||||||
|
if got := SeedFromEnv().AWURL; got != "http://aw.example:9999" {
|
||||||
|
t.Fatalf("AWURL = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAWURLDefault(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AW_URL", "")
|
||||||
|
if got := SeedFromEnv().AWURL; got != "http://localhost:5600" {
|
||||||
|
t.Fatalf("default AWURL = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidAmbientMode(t *testing.T) {
|
||||||
|
for _, m := range []string{AmbientOff, AmbientStatus, AmbientNotify} {
|
||||||
|
if !ValidAmbientMode(m) {
|
||||||
|
t.Fatalf("%q should be valid", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ValidAmbientMode("loud") {
|
||||||
|
t.Fatal("loud should be invalid")
|
||||||
|
}
|
||||||
|
if ValidAmbientMode("") {
|
||||||
|
t.Fatal("empty should be invalid (callers default before validating)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAmbientDefaults(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AMBIENT_MODE", "")
|
||||||
|
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
|
||||||
|
s := SeedFromEnv()
|
||||||
|
if s.AmbientMode != AmbientNotify {
|
||||||
|
t.Fatalf("ambient_mode default = %q, want %q", s.AmbientMode, AmbientNotify)
|
||||||
|
}
|
||||||
|
if s.AmbientCadenceSecs != 300 {
|
||||||
|
t.Fatalf("ambient_cadence_secs default = %d, want 300", s.AmbientCadenceSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvAmbientFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AMBIENT_MODE", "status")
|
||||||
|
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "120")
|
||||||
|
s := SeedFromEnv()
|
||||||
|
if s.AmbientMode != "status" || s.AmbientCadenceSecs != 120 {
|
||||||
|
t.Fatalf("env not honored: mode=%q cadence=%d", s.AmbientMode, s.AmbientCadenceSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package statusfile mirrors the controller's runtime status into a single-line
|
// Package statusfile mirrors the harness's runtime status into a single-line
|
||||||
// file (~/.antidrift_status) so an external consumer — a window-manager status
|
// file (~/.keel_status) so an external consumer — a window-manager status
|
||||||
// bar — can display it with a plain file read.
|
// bar — can display it with a plain file read.
|
||||||
package statusfile
|
package statusfile
|
||||||
|
|
||||||
@@ -9,20 +9,22 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode"
|
||||||
"antidrift/internal/session"
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// statusFileName is the file written under the user's home directory.
|
// 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
|
// 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.
|
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
|
||||||
const interval = time.Minute
|
const interval = time.Minute
|
||||||
|
|
||||||
// DefaultPath resolves ~/.antidrift_status.
|
// DefaultPath resolves ~/.keel_status.
|
||||||
func DefaultPath() (string, error) {
|
func DefaultPath() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,10 +33,62 @@ func DefaultPath() (string, error) {
|
|||||||
return filepath.Join(home, statusFileName), nil
|
return filepath.Join(home, statusFileName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render produces the single status line for the given state. It is empty for
|
// Render produces the single status line for the harness envelope, dispatching
|
||||||
// Locked/idle so a bar module can hide itself. Drift status strings ("drifting")
|
// 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.
|
// 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 {
|
switch st.RuntimeState {
|
||||||
case domain.RuntimeActive:
|
case domain.RuntimeActive:
|
||||||
timer := remaining(st, now)
|
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
|
// 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
|
// 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.
|
// 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 {
|
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
|
||||||
return "0m"
|
return "0m"
|
||||||
}
|
}
|
||||||
@@ -70,25 +124,43 @@ func remaining(st session.State, now time.Time) string {
|
|||||||
return fmt.Sprintf("%dm", mins)
|
return fmt.Sprintf("%dm", mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writer periodically renders the controller state and writes it to a file,
|
// Writer periodically renders the harness envelope (plus the ambient line) and
|
||||||
// rewriting only when the line changes.
|
// writes it to a file, rewriting only when the line changes.
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
path string
|
path string
|
||||||
state func() session.State
|
state func() mode.Envelope
|
||||||
|
ambient func() string
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
last string
|
last string
|
||||||
wrote bool
|
wrote bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWriter builds a Writer for path, reading state via the given accessor.
|
// NewWriter builds a Writer for path, reading the harness envelope via state and
|
||||||
func NewWriter(path string, state func() session.State) *Writer {
|
// the ambient coaching line via ambient (either may be supplied; a nil ambient
|
||||||
return &Writer{path: path, state: state, now: time.Now}
|
// 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)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run writes the status file immediately, then on every tick when the rendered
|
// Wake asks the writer to re-render now rather than at the next tick. It is the
|
||||||
// line has changed. It removes the file on ctx cancellation so a stale status
|
// hook the harness's change notifications fire through, so drift transitions
|
||||||
// does not linger after shutdown.
|
// 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,
|
||||||
|
// so concurrent callers never race on the file.
|
||||||
|
func (w *Writer) Wake() {
|
||||||
|
select {
|
||||||
|
case w.wake <- struct{}{}:
|
||||||
|
default: // a re-render is already pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run writes the status file immediately, then re-renders on each wake or tick
|
||||||
|
// when the rendered line has changed. The tick still advances the minute
|
||||||
|
// countdown when nothing else changes. It removes the file on ctx cancellation
|
||||||
|
// so a stale status does not linger after shutdown.
|
||||||
func (w *Writer) Run(ctx context.Context) {
|
func (w *Writer) Run(ctx context.Context) {
|
||||||
t := time.NewTicker(interval)
|
t := time.NewTicker(interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -98,6 +170,8 @@ func (w *Writer) Run(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = os.Remove(w.path)
|
_ = os.Remove(w.path)
|
||||||
return
|
return
|
||||||
|
case <-w.wake:
|
||||||
|
w.write()
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
w.write()
|
w.write()
|
||||||
}
|
}
|
||||||
@@ -105,14 +179,18 @@ func (w *Writer) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Writer) write() {
|
func (w *Writer) write() {
|
||||||
line := Render(w.state(), w.now())
|
line := ""
|
||||||
if w.wrote && line == w.last {
|
if w.ambient != nil {
|
||||||
|
line = w.ambient()
|
||||||
|
}
|
||||||
|
rendered := Render(w.state(), line, w.now())
|
||||||
|
if w.wrote && rendered == w.last {
|
||||||
return
|
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)
|
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.last = line
|
w.last = rendered
|
||||||
w.wrote = true
|
w.wrote = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,32 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode"
|
||||||
"antidrift/internal/session"
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func activeState(deadline int64, driftStatus, nudge string) session.State {
|
// focusEnv wraps a focus.State in the harness envelope the writer now consumes.
|
||||||
st := session.State{
|
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,
|
RuntimeState: domain.RuntimeActive,
|
||||||
Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline},
|
Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline},
|
||||||
}
|
}
|
||||||
if driftStatus != "" || nudge != "" {
|
if driftStatus != "" || nudge != "" {
|
||||||
st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge}
|
st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -28,25 +40,33 @@ func TestRender(t *testing.T) {
|
|||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
st session.State
|
env mode.Envelope
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""},
|
{"idle", mode.Envelope{}, "idle"},
|
||||||
{"empty runtime", session.State{}, ""},
|
{"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""},
|
||||||
{"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"},
|
{"empty focus runtime", focusEnv(focus.State{}), ""},
|
||||||
{"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"},
|
{"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"},
|
||||||
{"active ontask", activeState(in24m, "ontask", ""), "● 24m"},
|
{"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"},
|
||||||
{"active no drift view", activeState(in24m, "", ""), "● 24m"},
|
{"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"},
|
||||||
{"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"},
|
{"active no drift view", focusEnv(activeState(in24m, "", "")), "● 24m"},
|
||||||
{"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"},
|
{"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"},
|
||||||
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"},
|
{"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), "● 24m ·?"},
|
||||||
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"},
|
{"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"},
|
||||||
{"active past deadline", activeState(now.Add(-5 * time.Minute).Unix(), "ontask", ""), "● 0m"},
|
{"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"},
|
||||||
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"},
|
{"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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
t.Errorf("Render() = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,8 +76,8 @@ func TestRender(t *testing.T) {
|
|||||||
func TestWriterWritesAndDedups(t *testing.T) {
|
func TestWriterWritesAndDedups(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
now := time.Unix(1_000_000, 0)
|
now := time.Unix(1_000_000, 0)
|
||||||
state := session.State{RuntimeState: domain.RuntimePlanning}
|
env := focusEnv(focusState(domain.RuntimePlanning))
|
||||||
w := NewWriter(path, func() session.State { return state })
|
w := NewWriter(path, func() mode.Envelope { return env }, nil)
|
||||||
w.now = func() time.Time { return now }
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
w.write()
|
w.write()
|
||||||
@@ -76,7 +96,7 @@ func TestWriterWritesAndDedups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Changed state: rewrites.
|
// Changed state: rewrites.
|
||||||
state = session.State{RuntimeState: domain.RuntimeLocked}
|
env = focusEnv(focusState(domain.RuntimeLocked))
|
||||||
w.write()
|
w.write()
|
||||||
if got := readFile(t, path); got != "" {
|
if got := readFile(t, path); got != "" {
|
||||||
t.Errorf("changed write = %q, want empty", got)
|
t.Errorf("changed write = %q, want empty", got)
|
||||||
@@ -85,9 +105,9 @@ func TestWriterWritesAndDedups(t *testing.T) {
|
|||||||
|
|
||||||
func TestWriterRemovesFileOnCancel(t *testing.T) {
|
func TestWriterRemovesFileOnCancel(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
w := NewWriter(path, func() session.State {
|
w := NewWriter(path, func() mode.Envelope {
|
||||||
return session.State{RuntimeState: domain.RuntimePlanning}
|
return focusEnv(focusState(domain.RuntimePlanning))
|
||||||
})
|
}, nil)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() { w.Run(ctx); close(done) }()
|
go func() { w.Run(ctx); close(done) }()
|
||||||
@@ -101,6 +121,59 @@ func TestWriterRemovesFileOnCancel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wake makes the writer re-render on a state change instead of waiting for the
|
||||||
|
// coarse minute tick, so the status bar tracks drift transitions promptly
|
||||||
|
// instead of lagging the web UI by up to a minute.
|
||||||
|
func TestWriterWakeRendersPromptly(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
in10m := now.Add(10 * time.Minute).Unix()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
st := activeState(in10m, "ontask", "")
|
||||||
|
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())
|
||||||
|
defer cancel()
|
||||||
|
go w.Run(ctx)
|
||||||
|
|
||||||
|
waitFor(t, func() bool { return fileEquals(path, "● 10m") })
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
st = activeState(in10m, "drifting", "")
|
||||||
|
mu.Unlock()
|
||||||
|
w.Wake()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func readFile(t *testing.T, path string) string {
|
func readFile(t *testing.T, path string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Snapshot is the persisted current state of the daemon.
|
// Snapshot is the persisted current state of the daemon.
|
||||||
@@ -29,13 +29,13 @@ type Snapshot struct {
|
|||||||
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
|
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns ~/.antidrift/state.json.
|
// DefaultPath returns ~/.keel/state.json.
|
||||||
func DefaultPath() (string, error) {
|
func DefaultPath() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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.
|
// Load reads a snapshot. A missing file yields a default Locked snapshot.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadMissingFileReturnsLocked(t *testing.T) {
|
func TestLoadMissingFileReturnsLocked(t *testing.T) {
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ func execRunner(ctx context.Context, name string, args ...string) ([]byte, error
|
|||||||
// (`am --json`, no subcommand) and parses the open tasks due today or earlier.
|
// (`am --json`, no subcommand) and parses the open tasks due today or earlier.
|
||||||
type Marvin struct {
|
type Marvin struct {
|
||||||
cmd string
|
cmd string
|
||||||
args []string
|
base []string // configured prefix after cmd, without the "--json" sentinel
|
||||||
|
args []string // read-path argv: base + ["--json"]
|
||||||
run runner
|
run runner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +44,9 @@ func NewMarvin(command string) *Marvin {
|
|||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
fields = []string{"am"}
|
fields = []string{"am"}
|
||||||
}
|
}
|
||||||
args := append([]string{}, fields[1:]...)
|
base := append([]string{}, fields[1:]...)
|
||||||
args = append(args, "--json")
|
args := append(append([]string{}, base...), "--json")
|
||||||
return &Marvin{cmd: fields[0], args: args, run: execRunner}
|
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
|
// 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)
|
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 {
|
type rawTask struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tasks
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,3 +107,35 @@ func TestTodayPropagatesError(t *testing.T) {
|
|||||||
t.Fatal("want error from failing runner")
|
t.Fatal("want error from failing runner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) {
|
||||||
|
m := NewMarvin("uv run am")
|
||||||
|
var gotName string
|
||||||
|
var gotArgs []string
|
||||||
|
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
gotName, gotArgs = name, args
|
||||||
|
return []byte(`{"id":"task-1"}`), nil
|
||||||
|
}
|
||||||
|
if err := m.Create(context.Background(), NewTask("buy groceries")); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
// Same prefix the read path shells out through, so add goes through the
|
||||||
|
// same ampy entrypoint and preserves its CRDT bookkeeping.
|
||||||
|
if gotName != "uv" {
|
||||||
|
t.Fatalf("runner name = %q, want %q", gotName, "uv")
|
||||||
|
}
|
||||||
|
joined := strings.Join(gotArgs, " ")
|
||||||
|
if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") {
|
||||||
|
t.Fatalf("args = %v, want marvin add with title", gotArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePropagatesError(t *testing.T) {
|
||||||
|
m := NewMarvin("am")
|
||||||
|
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, errors.New("am add failed")
|
||||||
|
}
|
||||||
|
if err := m.Create(context.Background(), NewTask("buy groceries")); err == nil {
|
||||||
|
t.Fatal("want error from failing runner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ type Task struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Provider answers "what should I be doing?" — the open tasks due today or
|
// Provider answers "what should I be doing?" — the open tasks due today or
|
||||||
// earlier.
|
// earlier — and lets callers add new tasks.
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Today(ctx context.Context) ([]Task, error)
|
Today(ctx context.Context) ([]Task, error)
|
||||||
|
Create(ctx context.Context, t Task) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTask builds a minimal task for creation (title only in v1).
|
||||||
|
func NewTask(title string) Task { return Task{Title: title} }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"antidrift/internal/settings"
|
"keel/internal/settings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -45,6 +45,19 @@ func (s *Server) handlePostSettings(c *gin.Context) {
|
|||||||
s.settingsMu.Lock()
|
s.settingsMu.Lock()
|
||||||
apply := s.applyFn
|
apply := s.applyFn
|
||||||
path := s.settingsPath
|
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()
|
s.settingsMu.Unlock()
|
||||||
if apply == nil {
|
if apply == nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
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
|
// 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 —
|
// 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).
|
// beyond OS permissions (same trust boundary as the rest of the UI).
|
||||||
func (s *Server) handleBrowse(c *gin.Context) {
|
func (s *Server) handleBrowse(c *gin.Context) {
|
||||||
dir := strings.TrimSpace(c.Query("dir"))
|
dir := strings.TrimSpace(c.Query("dir"))
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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
|
// 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) {
|
func TestGetSettingsReturnsCurrent(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
||||||
fa := &fakeApplier{}
|
fa := &fakeApplier{}
|
||||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
|
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) {
|
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
path := filepath.Join(t.TempDir(), "settings.json")
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
fa := &fakeApplier{}
|
fa := &fakeApplier{}
|
||||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||||
@@ -78,7 +80,7 @@ func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
path := filepath.Join(t.TempDir(), "settings.json")
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
fa := &fakeApplier{reject: true}
|
fa := &fakeApplier{reject: true}
|
||||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||||
@@ -97,7 +99,7 @@ func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
fa := &fakeApplier{}
|
fa := &fakeApplier{}
|
||||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
|
||||||
r := s.Router()
|
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) {
|
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||||
@@ -123,7 +156,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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 })
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
|
|
||||||
@@ -179,7 +212,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBrowseBadDirIs400(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 })
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ input {
|
|||||||
border-radius: 8px; color: var(--ink); font: inherit;
|
border-radius: 8px; color: var(--ink); font: inherit;
|
||||||
}
|
}
|
||||||
input:focus { outline: 0; border-color: var(--accent); }
|
input:focus { outline: 0; border-color: var(--accent); }
|
||||||
|
/* Checkboxes must opt out of the full-width text-input styling above, which
|
||||||
|
otherwise stretches the box across the row and shoves its label adrift. */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: auto; flex: none; margin: 0; padding: 0; accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.enforce-toggle { justify-content: flex-start; }
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
|
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
|
||||||
@@ -82,6 +88,10 @@ input:focus { outline: 0; border-color: var(--accent); }
|
|||||||
.btn-ghost { background: var(--line); color: var(--ink); }
|
.btn-ghost { background: var(--line); color: var(--ink); }
|
||||||
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
|
.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. */
|
/* 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 { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
|
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
|
||||||
@@ -170,3 +180,28 @@ input:focus { outline: 0; border-color: var(--accent); }
|
|||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
.browse-list button:hover { background: var(--line); color: var(--accent); }
|
.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; }
|
||||||
|
|||||||
+129
-12
@@ -2,6 +2,7 @@ const app = document.getElementById('app');
|
|||||||
const view = document.getElementById('view');
|
const view = document.getElementById('view');
|
||||||
let countdownTimer = null;
|
let countdownTimer = null;
|
||||||
let renderedState = null; // which runtime_state the DOM currently shows
|
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
|
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||||
|
|
||||||
function post(path, body) {
|
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="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
|
||||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
|
||||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +121,24 @@ function updateActiveDrift(state) {
|
|||||||
<span class="status-meta">on task · ${switches} switches</span>`;
|
<span class="status-meta">on task · ${switches} switches</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updatePlanningWindowHint shows the live foreground window class so the user
|
||||||
|
// can copy the exact token into the allowed-apps field — class names are
|
||||||
|
// otherwise invisible, and a non-matching token silently disables on-task
|
||||||
|
// matching. Clicking the chip appends the class if not already listed.
|
||||||
|
function updatePlanningWindowHint(state) {
|
||||||
|
const el = document.getElementById('appHint');
|
||||||
|
if (!el) return;
|
||||||
|
const cls = state.evidence && state.evidence.current && state.evidence.current.class;
|
||||||
|
if (!cls) { el.innerHTML = ''; return; }
|
||||||
|
el.innerHTML = `current window: <button type="button" class="link" id="addClass">${esc(cls)}</button>`;
|
||||||
|
document.getElementById('addClass').onclick = () => {
|
||||||
|
const apps = document.getElementById('apps');
|
||||||
|
if (!apps) return;
|
||||||
|
const have = apps.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (!have.includes(cls)) { have.push(cls); apps.value = have.join(', '); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function updatePlanningCoach(coach) {
|
function updatePlanningCoach(coach) {
|
||||||
const statusEl = document.getElementById('coachStatus');
|
const statusEl = document.getElementById('coachStatus');
|
||||||
if (!statusEl) return;
|
if (!statusEl) return;
|
||||||
@@ -205,7 +224,7 @@ function updatePlanningReflection(refl) {
|
|||||||
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
|
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(state) {
|
function renderFocus(state) {
|
||||||
setStateAttr(state);
|
setStateAttr(state);
|
||||||
const rs = state.runtime_state;
|
const rs = state.runtime_state;
|
||||||
if (rs === 'planning' && renderedState === 'planning') {
|
if (rs === 'planning' && renderedState === 'planning') {
|
||||||
@@ -213,6 +232,7 @@ function render(state) {
|
|||||||
updatePlanningTasks(state.tasks);
|
updatePlanningTasks(state.tasks);
|
||||||
updatePlanningKnowledge(state.knowledge);
|
updatePlanningKnowledge(state.knowledge);
|
||||||
updatePlanningReflection(state.reflection);
|
updatePlanningReflection(state.reflection);
|
||||||
|
updatePlanningWindowHint(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rs === 'active' && renderedState === 'active') {
|
if (rs === 'active' && renderedState === 'active') {
|
||||||
@@ -229,7 +249,7 @@ function render(state) {
|
|||||||
<p class="meta">No active commitment.</p>
|
<p class="meta">No active commitment.</p>
|
||||||
<button id="plan" class="btn btn-primary">Start planning</button>
|
<button id="plan" class="btn btn-primary">Start planning</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.getElementById('plan').onclick = () => post('/planning');
|
document.getElementById('plan').onclick = () => post('/mode/focus/start');
|
||||||
|
|
||||||
} else if (rs === 'planning') {
|
} else if (rs === 'planning') {
|
||||||
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
||||||
@@ -248,6 +268,7 @@ function render(state) {
|
|||||||
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
||||||
<label>Allowed apps (comma-separated window classes)</label>
|
<label>Allowed apps (comma-separated window classes)</label>
|
||||||
<input id="apps" placeholder="e.g. code, firefox">
|
<input id="apps" placeholder="e.g. code, firefox">
|
||||||
|
<div id="appHint" class="hint"></div>
|
||||||
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
|
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
|
||||||
<p class="hint">Minimize off-task windows when you drift.</p>
|
<p class="hint">Minimize off-task windows when you drift.</p>
|
||||||
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
||||||
@@ -256,7 +277,7 @@ function render(state) {
|
|||||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||||
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
||||||
[na, sc, mins].forEach(el => el.oninput = check);
|
[na, sc, mins].forEach(el => el.oninput = check);
|
||||||
start.onclick = () => post('/commitment', {
|
start.onclick = () => post('/mode/command/commitment', {
|
||||||
next_action: na.value.trim(),
|
next_action: na.value.trim(),
|
||||||
success_condition: sc.value.trim(),
|
success_condition: sc.value.trim(),
|
||||||
timebox_secs: Math.round(+mins.value * 60),
|
timebox_secs: Math.round(+mins.value * 60),
|
||||||
@@ -266,12 +287,13 @@ function render(state) {
|
|||||||
});
|
});
|
||||||
document.getElementById('sharpen').onclick = () => {
|
document.getElementById('sharpen').onclick = () => {
|
||||||
const intent = document.getElementById('intent').value.trim();
|
const intent = document.getElementById('intent').value.trim();
|
||||||
if (intent) post('/coach', { intent });
|
if (intent) post('/mode/command/coach', { intent });
|
||||||
};
|
};
|
||||||
updatePlanningCoach(state.coach);
|
updatePlanningCoach(state.coach);
|
||||||
updatePlanningTasks(state.tasks);
|
updatePlanningTasks(state.tasks);
|
||||||
updatePlanningKnowledge(state.knowledge);
|
updatePlanningKnowledge(state.knowledge);
|
||||||
updatePlanningReflection(state.reflection);
|
updatePlanningReflection(state.reflection);
|
||||||
|
updatePlanningWindowHint(state);
|
||||||
|
|
||||||
} else if (rs === 'active') {
|
} else if (rs === 'active') {
|
||||||
const c = state.commitment || {};
|
const c = state.commitment || {};
|
||||||
@@ -283,7 +305,7 @@ function render(state) {
|
|||||||
</div>
|
</div>
|
||||||
${evidenceBlock(state.evidence)}
|
${evidenceBlock(state.evidence)}
|
||||||
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
|
<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 t = document.getElementById('t');
|
||||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||||
tick();
|
tick();
|
||||||
@@ -301,16 +323,99 @@ function render(state) {
|
|||||||
${reflectionBlock(state.reflection)}
|
${reflectionBlock(state.reflection)}
|
||||||
${reviewSummary(state.evidence)}
|
${reviewSummary(state.evidence)}
|
||||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
<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 {
|
} else {
|
||||||
view.textContent = rs;
|
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');
|
const es = new EventSource('/events');
|
||||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
es.onmessage = (e) => route(JSON.parse(e.data));
|
||||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; };
|
||||||
|
|
||||||
// ---- Settings overlay ----
|
// ---- Settings overlay ----
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
@@ -328,10 +433,18 @@ function openSettings() {
|
|||||||
<input id="setMarvin" placeholder="uv run am">
|
<input id="setMarvin" placeholder="uv run am">
|
||||||
<label>Knowledge profile path</label>
|
<label>Knowledge profile path</label>
|
||||||
<div class="path-row">
|
<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>
|
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="browsePane" class="browse" hidden></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 id="setError" class="set-error"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
||||||
@@ -341,6 +454,8 @@ function openSettings() {
|
|||||||
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
||||||
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
||||||
document.getElementById('setKnow').value = s.knowledge_path || '';
|
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;
|
ov.hidden = false;
|
||||||
document.getElementById('setCancel').onclick = closeSettings;
|
document.getElementById('setCancel').onclick = closeSettings;
|
||||||
document.getElementById('setSave').onclick = saveSettings;
|
document.getElementById('setSave').onclick = saveSettings;
|
||||||
@@ -366,6 +481,8 @@ function saveSettings() {
|
|||||||
ai_backend: document.getElementById('setBackend').value,
|
ai_backend: document.getElementById('setBackend').value,
|
||||||
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
||||||
knowledge_path: document.getElementById('setKnow').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 => {
|
post('/settings', body).then(r => {
|
||||||
if (r.ok) { closeSettings(); return; }
|
if (r.ok) { closeSettings(); return; }
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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" href="/favicon.ico" sizes="any">
|
||||||
<link rel="icon" type="image/png" href="/favicon.png">
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
<link rel="stylesheet" href="/app.css">
|
<link rel="stylesheet" href="/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="app">
|
<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 class="card" id="view">connecting…</div>
|
||||||
<div id="settingsOverlay" class="overlay" hidden></div>
|
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+97
-87
@@ -7,15 +7,16 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/harness"
|
||||||
"antidrift/internal/session"
|
"keel/internal/mode"
|
||||||
"antidrift/internal/settings"
|
"keel/internal/mode/focus/statemachine"
|
||||||
"antidrift/internal/statemachine"
|
"keel/internal/settings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -23,11 +24,14 @@ import (
|
|||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
// Server wires the session controller to HTTP and SSE.
|
// Server wires the harness to HTTP and SSE.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
ctrl *session.Controller
|
h *harness.Harness
|
||||||
bcast *Broadcaster
|
bcast *Broadcaster
|
||||||
|
|
||||||
|
ambientLine func() string
|
||||||
|
ambientSnooze func(time.Duration)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
|
|
||||||
@@ -38,16 +42,20 @@ type Server struct {
|
|||||||
applyMu sync.Mutex // serializes POST /settings apply+save
|
applyMu sync.Mutex // serializes POST /settings apply+save
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(ctrl *session.Controller) *Server {
|
// NewServer wires the harness to the SSE broadcaster: every harness change
|
||||||
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()}
|
// (mode start, command, async role completion, expiry) fans out the current
|
||||||
ctrl.SetOnChange(s.broadcast)
|
// 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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Router() *gin.Engine {
|
func (s *Server) Router() *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
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.
|
// forwarded-IP headers. This also silences gin's trust-all-proxies warning.
|
||||||
_ = r.SetTrustedProxies(nil)
|
_ = r.SetTrustedProxies(nil)
|
||||||
|
|
||||||
@@ -68,102 +76,103 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
c.FileFromFS("/favicon.png", http.FS(sub))
|
c.FileFromFS("/favicon.png", http.FS(sub))
|
||||||
})
|
})
|
||||||
r.GET("/events", s.handleEvents)
|
r.GET("/events", s.handleEvents)
|
||||||
r.POST("/planning", s.handlePlanning)
|
r.POST("/mode/:kind/start", s.handleStart)
|
||||||
r.POST("/coach", s.handleCoach)
|
r.POST("/mode/command/:name", s.handleCommand)
|
||||||
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.GET("/settings", s.handleGetSettings)
|
r.GET("/settings", s.handleGetSettings)
|
||||||
r.POST("/settings", s.handlePostSettings)
|
r.POST("/settings", s.handlePostSettings)
|
||||||
r.GET("/fs/browse", s.handleBrowse)
|
r.GET("/fs/browse", s.handleBrowse)
|
||||||
|
r.POST("/ambient/snooze", s.handleAmbientSnooze)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) stateJSON() string {
|
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)
|
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() {
|
func (s *Server) broadcast() {
|
||||||
s.bcast.Publish(s.stateJSON())
|
s.bcast.Publish(s.stateJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
// respond maps a controller error to an HTTP status, otherwise broadcasts and
|
// respond maps a harness/mode error to an HTTP status, otherwise returns the
|
||||||
// returns the new state.
|
// 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) {
|
func (s *Server) respond(c *gin.Context, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var illegal statemachine.IllegalTransitionError
|
var illegal statemachine.IllegalTransitionError
|
||||||
if errors.As(err, &illegal) {
|
switch {
|
||||||
|
case errors.As(err, &illegal):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.broadcast()
|
|
||||||
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePlanning(c *gin.Context) {
|
// handleStart activates a mode by kind. Starting focus arms the expiry timer in
|
||||||
s.cancelExpiry()
|
// case the mode is restored or transitions straight into a deadline-bearing
|
||||||
s.respond(c, s.ctrl.EnterPlanning())
|
// state; the arm is a no-op when there is no deadline yet.
|
||||||
}
|
func (s *Server) handleStart(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
type coachRequest struct {
|
err := s.h.Start(kind)
|
||||||
Intent string `json:"intent"`
|
if err == nil && kind == "focus" {
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
s.armExpiry()
|
s.armExpiry()
|
||||||
}
|
}
|
||||||
s.respond(c, err)
|
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.cancelExpiry()
|
||||||
s.respond(c, s.ctrl.Complete())
|
|
||||||
}
|
}
|
||||||
|
s.respond(c, err)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleEvents(c *gin.Context) {
|
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() {
|
func (s *Server) armExpiry() {
|
||||||
deadline := s.ctrl.Deadline()
|
deadline := s.h.Deadline()
|
||||||
if deadline.IsZero() {
|
if deadline.IsZero() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -212,9 +224,7 @@ func (s *Server) armExpiry() {
|
|||||||
d = 0
|
d = 0
|
||||||
}
|
}
|
||||||
s.timer = time.AfterFunc(d, func() {
|
s.timer = time.AfterFunc(d, func() {
|
||||||
if err := s.ctrl.Expire(); err == nil {
|
_ = s.h.Expire()
|
||||||
s.broadcast()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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() {
|
func (s *Server) Init() {
|
||||||
st := s.ctrl.State()
|
deadline := s.h.Deadline()
|
||||||
if st.RuntimeState != "active" {
|
if deadline.IsZero() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.ctrl.Deadline().After(time.Now()) {
|
if deadline.After(time.Now()) {
|
||||||
s.armExpiry()
|
s.armExpiry()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.ctrl.Expire(); err == nil {
|
_ = s.h.Expire()
|
||||||
s.broadcast()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+218
-88
@@ -9,26 +9,46 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
"keel/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"keel/internal/evidence"
|
||||||
"antidrift/internal/evidence"
|
"keel/internal/harness"
|
||||||
"antidrift/internal/knowledge"
|
"keel/internal/knowledge"
|
||||||
"antidrift/internal/session"
|
"keel/internal/mode"
|
||||||
"antidrift/internal/settings"
|
"keel/internal/mode/focus"
|
||||||
"antidrift/internal/tasks"
|
"keel/internal/mode/focus/domain"
|
||||||
|
"keel/internal/settings"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
ctrl, err := session.New(path)
|
m, err := focus.New(path)
|
||||||
if err != nil {
|
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 {
|
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
|
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) {
|
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
r := s.Router()
|
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)
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
}
|
}
|
||||||
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
|
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())
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
|
if m.State().RuntimeState != domain.RuntimeActive {
|
||||||
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
|
t.Fatalf("expected Active, got %s", m.State().RuntimeState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommitmentRejectsInvalidInput(t *testing.T) {
|
func TestCommitmentRejectsInvalidInput(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
_ = post(t, r, "/planning", "")
|
_ = post(t, r, "/mode/command/planning", "")
|
||||||
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
|
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 {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIllegalTransitionReturns409(t *testing.T) {
|
func TestIllegalTransitionReturns409(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
// /complete from Locked is illegal.
|
// /complete from Locked is illegal.
|
||||||
w := post(t, r, "/complete", "")
|
w := post(t, r, "/mode/command/complete", "")
|
||||||
if w.Code != http.StatusConflict {
|
if w.Code != http.StatusConflict {
|
||||||
t.Fatalf("expected 409, got %d", w.Code)
|
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) {
|
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
r := s.Router()
|
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}`
|
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())
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
// A focus update should appear in the serialized state.
|
// A focus update should appear in the serialized state envelope.
|
||||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}})
|
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
|
||||||
|
|
||||||
js := s.stateJSON()
|
js := s.stateJSON()
|
||||||
if !strings.Contains(js, `"evidence"`) {
|
if !strings.Contains(js, `"evidence"`) {
|
||||||
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLockedStateHasNullEvidence(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()
|
js := s.stateJSON()
|
||||||
if !strings.Contains(js, `"evidence":null`) {
|
if !strings.Contains(js, `"evidence":null`) {
|
||||||
t.Fatalf("locked payload should have null evidence: %s", js)
|
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) {
|
func TestCoachRouteHappyPath(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
|
m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
_ = post(t, r, "/planning", "")
|
_ = post(t, r, "/mode/command/planning", "")
|
||||||
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
|
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())
|
t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
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
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(5 * time.Millisecond)
|
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) {
|
func TestCoachRouteOutsidePlanning(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
s.ctrl.SetCoach(stubCoach{})
|
m.SetCoach(stubCoach{})
|
||||||
r := s.Router()
|
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)
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoachRouteInvalidJSON(t *testing.T) {
|
func TestCoachRouteInvalidJSON(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
_ = post(t, r, "/planning", "")
|
_ = post(t, r, "/mode/command/planning", "")
|
||||||
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
|
if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoachRouteUnavailableDegrades(t *testing.T) {
|
func TestCoachRouteUnavailableDegrades(t *testing.T) {
|
||||||
s := newTestServer(t) // no SetCoach
|
s, m := newTestServer(t) // no SetCoach
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
_ = post(t, r, "/planning", "")
|
_ = post(t, r, "/mode/command/planning", "")
|
||||||
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
|
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)
|
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)
|
t.Fatalf("want error status, got %+v", cv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
|
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
r := s.Router()
|
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)
|
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)
|
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) {
|
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
||||||
r := s.Router()
|
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"]}`
|
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())
|
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
|
// Two distinct on-task titles trip the nudge (history >= 2; first call has no
|
||||||
// debounce to wait on).
|
// debounce to wait on).
|
||||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
|
m.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: "b.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
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
|
break
|
||||||
}
|
}
|
||||||
time.Sleep(5 * time.Millisecond)
|
time.Sleep(5 * time.Millisecond)
|
||||||
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServesStaticAssets(t *testing.T) {
|
func TestServesStaticAssets(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
|||||||
return s.list, nil
|
return s.list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
|
||||||
|
|
||||||
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
|
m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
|
||||||
r := s.Router()
|
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)
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
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
|
break
|
||||||
}
|
}
|
||||||
time.Sleep(5 * time.Millisecond)
|
time.Sleep(5 * time.Millisecond)
|
||||||
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
r := s.Router()
|
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"]}`
|
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())
|
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)
|
t.Fatalf("commitment did not carry allowed classes: %#v", got)
|
||||||
}
|
}
|
||||||
// Now Active: overrides succeed.
|
// 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)
|
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)
|
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) {
|
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}})
|
m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
|
||||||
r := s.Router()
|
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)
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
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
|
break
|
||||||
}
|
}
|
||||||
time.Sleep(5 * time.Millisecond)
|
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) {
|
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
|
m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
_ = post(t, r, "/planning", "")
|
_ = post(t, r, "/mode/command/planning", "")
|
||||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
|
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())
|
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)
|
t.Fatalf("/complete code %d", w.Code)
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
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
|
break
|
||||||
}
|
}
|
||||||
time.Sleep(5 * time.Millisecond)
|
time.Sleep(5 * time.Millisecond)
|
||||||
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
|||||||
t.Fatalf("review payload missing recap: %s", js)
|
t.Fatalf("review payload missing recap: %s", js)
|
||||||
}
|
}
|
||||||
|
|
||||||
// End -> Locked -> Planning: the carry-forward should surface on planning.
|
// End -> Locked: the harness releases the focus mode and goes idle. Re-start
|
||||||
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
|
// 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)
|
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)
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
}
|
}
|
||||||
js2 := s.stateJSON()
|
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 }
|
type stubJudge struct{ verdict ai.Verdict }
|
||||||
|
|
||||||
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
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) {
|
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
r := s.Router()
|
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)
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
}
|
}
|
||||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
|
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())
|
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)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -397,15 +477,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s, m := newTestServer(t)
|
||||||
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
||||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
|
||||||
settings.Settings{}, func(ss settings.Settings) error {
|
settings.Settings{}, func(ss settings.Settings) error {
|
||||||
s.ctrl.SetKnowledgePath(ss.KnowledgePath)
|
m.SetKnowledgePath(ss.KnowledgePath)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
r := s.Router()
|
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)
|
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 {
|
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)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
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
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(5 * time.Millisecond)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ClassFromImagePath derives the on-task class identity from a process image
|
||||||
|
// path: the base file name with any trailing ".exe" removed (case-insensitive),
|
||||||
|
// lowercased. It is the Windows analog of an X11 WM_CLASS. It parses both `\`
|
||||||
|
// and `/` separators so it is correct regardless of the host OS running the
|
||||||
|
// test. Returns "" for an empty path.
|
||||||
|
func ClassFromImagePath(p string) string {
|
||||||
|
if i := strings.LastIndexAny(p, `\/`); i >= 0 {
|
||||||
|
p = p[i+1:]
|
||||||
|
}
|
||||||
|
const exeSuffix = ".exe"
|
||||||
|
if len(p) >= len(exeSuffix) && strings.EqualFold(p[len(p)-len(exeSuffix):], exeSuffix) {
|
||||||
|
p = p[:len(p)-len(exeSuffix)]
|
||||||
|
}
|
||||||
|
return strings.ToLower(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package winapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClassFromImagePath(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{`C:\Program Files\Microsoft VS Code\Code.exe`, "code"},
|
||||||
|
{`C:\Windows\explorer.exe`, "explorer"},
|
||||||
|
{`chrome.exe`, "chrome"},
|
||||||
|
{`C:\x\FOO.EXE`, "foo"},
|
||||||
|
{`C:\x\My.App.exe`, "my.app"},
|
||||||
|
{`C:/forward/slash/Code.exe`, "code"},
|
||||||
|
{`firefox`, "firefox"},
|
||||||
|
{``, ""},
|
||||||
|
{`.exe`, ""}, // degenerate: only the extension
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ClassFromImagePath(c.in); got != c.want {
|
||||||
|
t.Errorf("ClassFromImagePath(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
procGetWindowTextW = user32.NewProc("GetWindowTextW")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForegroundWindow returns the current foreground window's handle (as a uintptr
|
||||||
|
// so platform-neutral callers need not import windows), its title, and its
|
||||||
|
// on-task class (process exe base name; see ClassFromImagePath). ok is false
|
||||||
|
// when GetForegroundWindow returns null, which happens transiently during focus
|
||||||
|
// changes and some secure-desktop transitions (UAC / lock screen).
|
||||||
|
func ForegroundWindow() (hwnd uintptr, title, class string, ok bool) {
|
||||||
|
h := windows.GetForegroundWindow()
|
||||||
|
if h == 0 {
|
||||||
|
return 0, "", "", false
|
||||||
|
}
|
||||||
|
return uintptr(h), windowTitle(h), windowClass(h), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowTitle(h windows.HWND) string {
|
||||||
|
// 512 uint16s; titles over 511 chars are truncated — acceptable for a sensor tag.
|
||||||
|
const max = 512
|
||||||
|
buf := make([]uint16, max)
|
||||||
|
n, _, _ := procGetWindowTextW.Call(
|
||||||
|
uintptr(h),
|
||||||
|
uintptr(unsafe.Pointer(&buf[0])),
|
||||||
|
uintptr(max),
|
||||||
|
)
|
||||||
|
// n==0 covers both call failure and a genuinely empty title; both are fine here.
|
||||||
|
return windows.UTF16ToString(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowClass(h windows.HWND) string {
|
||||||
|
var pid uint32
|
||||||
|
if _, err := windows.GetWindowThreadProcessId(h, &pid); err != nil || pid == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(proc)
|
||||||
|
|
||||||
|
buf := make([]uint16, windows.MAX_PATH)
|
||||||
|
size := uint32(len(buf))
|
||||||
|
// MAX_PATH suffices for almost all paths; an over-long path fails here and
|
||||||
|
// degrades windowClass to "" (window left unclassified), never a crash.
|
||||||
|
if err := windows.QueryFullProcessImageName(proc, 0, &buf[0], &size); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ClassFromImagePath(windows.UTF16ToString(buf[:size]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinimizeForeground minimizes the current foreground window. It returns nil
|
||||||
|
// when nothing is focused. ShowWindow's BOOL return reports prior visibility,
|
||||||
|
// not success, so there is nothing to error-check; minimize is best-effort by
|
||||||
|
// the Guard contract.
|
||||||
|
func MinimizeForeground() error {
|
||||||
|
h := windows.GetForegroundWindow()
|
||||||
|
if h == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
procShowWindow.Call(uintptr(h), uintptr(windows.SW_MINIMIZE))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Generated
-1102
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "antidrift"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1.0.86"
|
|
||||||
fs2 = "0.4"
|
|
||||||
hex = "0.4.3"
|
|
||||||
ratatui = "0.27.0"
|
|
||||||
regex = "1.10.5"
|
|
||||||
shellexpand = "3.1.0"
|
|
||||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
sha2 = "0.10.8"
|
|
||||||
uuid = { version = "1", features = ["v7"] }
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "windef"] }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Name=AntiDrift
|
|
||||||
Exec=antidrift
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
|
||||||
StartupNotify=false
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
pub const APP_TITLE: &str = "AntiDrift";
|
|
||||||
pub const DEFAULT_DURATION: &str = "25";
|
|
||||||
pub const DURATION_TITLE: &str = "Duration";
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence";
|
|
||||||
pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! ";
|
|
||||||
pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl";
|
|
||||||
pub const INTENTION_TITLE: &str = "Intention";
|
|
||||||
pub const PAUSED: &str = "paused";
|
|
||||||
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
|
|
||||||
pub const PROVIDE_INTENTION: &str = "Provide intention! ";
|
|
||||||
pub const PROVIDE_VALID_DURATION: &str = "Provide valid duration in minutes! ";
|
|
||||||
pub const RATE_TITLES: &str = "Press 1, 2, 3 to rate titles!";
|
|
||||||
pub const READY_TO_START: &str = "Ready to start next session.";
|
|
||||||
pub const SESSION_IN_PROGRESS: &str = "Session In-Progress";
|
|
||||||
pub const SESSION_PAUSED: &str = "Session is paused. Unpause with 'p'.";
|
|
||||||
pub const SESSION_STATS_TITLE: &str = "Session Stats";
|
|
||||||
pub const STATUS_FILE: &str = "~/.antidrift_status";
|
|
||||||
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
|
|
||||||
pub const STATUS_TITLE: &str = "Status";
|
|
||||||
pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session";
|
|
||||||
pub const STATUS_LOCKED: &str = "🔒 antidrift locked";
|
|
||||||
pub const STATUS_PLANNING: &str = "🧭 antidrift planning";
|
|
||||||
pub const STATUS_PAUSED: &str = "⏸ antidrift paused";
|
|
||||||
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
|
|
||||||
pub const STATUS_QUIT: &str = "antidrift shutting down";
|
|
||||||
pub const STATUS_TRANSITION: &str = "↔ antidrift transition";
|
|
||||||
pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition";
|
|
||||||
pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes";
|
|
||||||
pub const TRANSITION_REASON_TITLE: &str = "Transition Reason";
|
|
||||||
pub const TRANSITION_RETURN_TITLE: &str = "Return Target";
|
|
||||||
pub const VIOLATION_TITLE: &str = "Violation";
|
|
||||||
pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason";
|
|
||||||
pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue.";
|
|
||||||
pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! ";
|
|
||||||
pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! ";
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
use crate::domain::AllowedContext;
|
|
||||||
|
|
||||||
pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = normalize_domain(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.domains.iter().any(|allowed| {
|
|
||||||
let allowed = normalize_domain(allowed);
|
|
||||||
!allowed.is_empty()
|
|
||||||
&& (candidate == allowed
|
|
||||||
|| candidate
|
|
||||||
.strip_suffix(&allowed)
|
|
||||||
.is_some_and(|prefix| prefix.ends_with('.')))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = normalize_casefolded(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context
|
|
||||||
.window_classes
|
|
||||||
.iter()
|
|
||||||
.any(|allowed| normalize_casefolded(allowed) == candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = normalize_casefolded(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.window_title_substrings.iter().any(|allowed| {
|
|
||||||
let allowed = normalize_casefolded(allowed);
|
|
||||||
!allowed.is_empty() && candidate.contains(&allowed)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = executable_basename(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context
|
|
||||||
.commands
|
|
||||||
.iter()
|
|
||||||
.any(|allowed| executable_basename(allowed) == candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_domain(value: &str) -> String {
|
|
||||||
value.trim().trim_end_matches('.').to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_casefolded(value: &str) -> String {
|
|
||||||
value.trim().to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn executable_basename(command: &str) -> String {
|
|
||||||
let executable = command.split_whitespace().next().unwrap_or_default();
|
|
||||||
executable
|
|
||||||
.trim()
|
|
||||||
.rsplit(['/', '\\'])
|
|
||||||
.next()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::domain::AllowedContext;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn domains_match_exact_or_subdomain() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
domains: vec!["github.com".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(domain_allowed(&context, "github.com"));
|
|
||||||
assert!(domain_allowed(&context, "docs.github.com"));
|
|
||||||
assert!(!domain_allowed(&context, "evilgithub.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn window_classes_match_case_insensitively_after_normalization() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_classes: vec!["code".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_class_allowed(&context, "Code"));
|
|
||||||
assert!(!window_class_allowed(&context, "firefox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn titles_match_by_substring() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_title_substrings: vec!["antidrift".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
|
|
||||||
assert!(!window_title_allowed(&context, "random video"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn domains_ignore_whitespace_case_and_trailing_dots() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
domains: vec![" GitHub.COM. ".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(domain_allowed(&context, " DOCS.GITHUB.COM. "));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn window_class_matching_trims_allowed_and_candidate_values() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_classes: vec![" code ".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_class_allowed(&context, " Code "));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn title_matching_trims_allowed_substrings() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_title_substrings: vec![" antidrift ".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_match_executable_basename() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
commands: vec!["cargo".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(command_allowed(
|
|
||||||
&context,
|
|
||||||
"/home/felixm/.cargo/bin/cargo test"
|
|
||||||
));
|
|
||||||
assert!(command_allowed(&context, "cargo"));
|
|
||||||
assert!(!command_allowed(&context, "/usr/bin/git status"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub const POLICY_SCHEMA_VERSION: u16 = 1;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CommitmentSource {
|
|
||||||
Manual,
|
|
||||||
Planner,
|
|
||||||
Recurring,
|
|
||||||
Recovery,
|
|
||||||
Template,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CommitmentState {
|
|
||||||
Draft,
|
|
||||||
Active,
|
|
||||||
Paused,
|
|
||||||
Completed,
|
|
||||||
Abandoned,
|
|
||||||
Violated,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum RuntimeState {
|
|
||||||
Locked,
|
|
||||||
Planning,
|
|
||||||
Active,
|
|
||||||
Transition,
|
|
||||||
Review,
|
|
||||||
AdminOverride,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EnforcementLevel {
|
|
||||||
Observe,
|
|
||||||
Warn,
|
|
||||||
Block,
|
|
||||||
Locked,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EvidenceHealth {
|
|
||||||
Available,
|
|
||||||
Degraded(String),
|
|
||||||
Unavailable(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
pub struct AllowedContext {
|
|
||||||
pub window_classes: Vec<String>,
|
|
||||||
pub window_title_substrings: Vec<String>,
|
|
||||||
pub domains: Vec<String>,
|
|
||||||
pub repos: Vec<String>,
|
|
||||||
pub commands: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct Commitment {
|
|
||||||
pub id: String,
|
|
||||||
pub created_at_unix_secs: u64,
|
|
||||||
pub source: CommitmentSource,
|
|
||||||
pub project_id: Option<String>,
|
|
||||||
pub template_id: Option<String>,
|
|
||||||
pub next_action: String,
|
|
||||||
pub success_condition: String,
|
|
||||||
pub timebox_secs: u64,
|
|
||||||
pub transition_policy_id: Option<String>,
|
|
||||||
pub state: CommitmentState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct PolicySnapshot {
|
|
||||||
pub id: String,
|
|
||||||
pub commitment_id: String,
|
|
||||||
pub schema_version: u16,
|
|
||||||
pub created_at_unix_secs: u64,
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub enforcement_level: EnforcementLevel,
|
|
||||||
pub allowed_context: AllowedContext,
|
|
||||||
pub required_monitors: Vec<String>,
|
|
||||||
pub violation_actions: Vec<String>,
|
|
||||||
pub expires_at_unix_secs: Option<u64>,
|
|
||||||
pub generated_by_agent_version: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum CommitmentValidationError {
|
|
||||||
MissingId,
|
|
||||||
MissingNextAction,
|
|
||||||
MissingSuccessCondition,
|
|
||||||
MissingTimebox,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for CommitmentValidationError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
CommitmentValidationError::MissingId => write!(f, "commitment id is required"),
|
|
||||||
CommitmentValidationError::MissingNextAction => write!(f, "next action is required"),
|
|
||||||
CommitmentValidationError::MissingSuccessCondition => {
|
|
||||||
write!(f, "success condition is required")
|
|
||||||
}
|
|
||||||
CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for CommitmentValidationError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum PolicySnapshotValidationError {
|
|
||||||
WrongSchemaVersion,
|
|
||||||
MissingId,
|
|
||||||
MissingCommitmentId,
|
|
||||||
MissingGeneratedByAgentVersion,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for PolicySnapshotValidationError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
PolicySnapshotValidationError::WrongSchemaVersion => {
|
|
||||||
write!(f, "policy schema version is unsupported")
|
|
||||||
}
|
|
||||||
PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"),
|
|
||||||
PolicySnapshotValidationError::MissingCommitmentId => {
|
|
||||||
write!(f, "commitment id is required")
|
|
||||||
}
|
|
||||||
PolicySnapshotValidationError::MissingGeneratedByAgentVersion => {
|
|
||||||
write!(f, "generated agent version is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for PolicySnapshotValidationError {}
|
|
||||||
|
|
||||||
impl Commitment {
|
|
||||||
pub fn new_manual(
|
|
||||||
next_action: impl Into<String>,
|
|
||||||
success_condition: impl Into<String>,
|
|
||||||
timebox: Duration,
|
|
||||||
) -> Result<Self, CommitmentValidationError> {
|
|
||||||
let next_action = next_action.into().trim().to_string();
|
|
||||||
let success_condition = success_condition.into().trim().to_string();
|
|
||||||
|
|
||||||
if timebox.as_secs() == 0 {
|
|
||||||
return Err(CommitmentValidationError::MissingTimebox);
|
|
||||||
}
|
|
||||||
|
|
||||||
let created_at_unix_secs = unix_secs_now();
|
|
||||||
let commitment = Self {
|
|
||||||
id: unique_id("commitment"),
|
|
||||||
created_at_unix_secs,
|
|
||||||
source: CommitmentSource::Manual,
|
|
||||||
project_id: None,
|
|
||||||
template_id: None,
|
|
||||||
next_action,
|
|
||||||
success_condition,
|
|
||||||
timebox_secs: timebox.as_secs(),
|
|
||||||
transition_policy_id: None,
|
|
||||||
state: CommitmentState::Draft,
|
|
||||||
};
|
|
||||||
commitment.validate()?;
|
|
||||||
Ok(commitment)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), CommitmentValidationError> {
|
|
||||||
if self.id.trim().is_empty() {
|
|
||||||
return Err(CommitmentValidationError::MissingId);
|
|
||||||
}
|
|
||||||
if self.next_action.trim().is_empty() {
|
|
||||||
return Err(CommitmentValidationError::MissingNextAction);
|
|
||||||
}
|
|
||||||
if self.success_condition.trim().is_empty() {
|
|
||||||
return Err(CommitmentValidationError::MissingSuccessCondition);
|
|
||||||
}
|
|
||||||
if self.timebox_secs == 0 {
|
|
||||||
return Err(CommitmentValidationError::MissingTimebox);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PolicySnapshot {
|
|
||||||
pub fn for_runtime(
|
|
||||||
commitment_id: String,
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
enforcement_level: EnforcementLevel,
|
|
||||||
allowed_context: AllowedContext,
|
|
||||||
) -> Self {
|
|
||||||
Self::try_for_runtime(
|
|
||||||
commitment_id,
|
|
||||||
runtime_state,
|
|
||||||
enforcement_level,
|
|
||||||
allowed_context,
|
|
||||||
)
|
|
||||||
.expect("runtime policy snapshot requires a non-empty commitment id")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_for_runtime(
|
|
||||||
commitment_id: String,
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
enforcement_level: EnforcementLevel,
|
|
||||||
allowed_context: AllowedContext,
|
|
||||||
) -> Result<Self, PolicySnapshotValidationError> {
|
|
||||||
let commitment_id = commitment_id.trim().to_string();
|
|
||||||
if commitment_id.is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingCommitmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let created_at_unix_secs = unix_secs_now();
|
|
||||||
let policy = Self {
|
|
||||||
id: unique_id("policy"),
|
|
||||||
commitment_id,
|
|
||||||
schema_version: POLICY_SCHEMA_VERSION,
|
|
||||||
created_at_unix_secs,
|
|
||||||
runtime_state,
|
|
||||||
enforcement_level,
|
|
||||||
allowed_context,
|
|
||||||
required_monitors: Vec::new(),
|
|
||||||
violation_actions: Vec::new(),
|
|
||||||
expires_at_unix_secs: None,
|
|
||||||
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
};
|
|
||||||
policy.validate()?;
|
|
||||||
Ok(policy)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> {
|
|
||||||
if self.schema_version != POLICY_SCHEMA_VERSION {
|
|
||||||
return Err(PolicySnapshotValidationError::WrongSchemaVersion);
|
|
||||||
}
|
|
||||||
if self.id.trim().is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingId);
|
|
||||||
}
|
|
||||||
if self.commitment_id.trim().is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingCommitmentId);
|
|
||||||
}
|
|
||||||
if self.generated_by_agent_version.trim().is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unix_secs_now() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_id(prefix: &str) -> String {
|
|
||||||
format!("{prefix}-{}", Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_requires_concrete_action_and_success_condition() {
|
|
||||||
let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500))
|
|
||||||
.expect_err("empty next action must be rejected");
|
|
||||||
assert_eq!(err, CommitmentValidationError::MissingNextAction);
|
|
||||||
|
|
||||||
let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500))
|
|
||||||
.expect_err("empty success condition must be rejected");
|
|
||||||
assert_eq!(err, CommitmentValidationError::MissingSuccessCondition);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_requires_nonzero_timebox() {
|
|
||||||
let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO)
|
|
||||||
.expect_err("zero timebox must be rejected");
|
|
||||||
assert_eq!(err, CommitmentValidationError::MissingTimebox);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_carries_runtime_contract() {
|
|
||||||
let commitment = Commitment::new_manual(
|
|
||||||
"Implement domain types",
|
|
||||||
"domain tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let policy = PolicySnapshot::for_runtime(
|
|
||||||
commitment.id.clone(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(policy.commitment_id, commitment.id);
|
|
||||||
assert_eq!(policy.runtime_state, RuntimeState::Active);
|
|
||||||
assert_eq!(policy.enforcement_level, EnforcementLevel::Warn);
|
|
||||||
assert_eq!(policy.schema_version, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_ids_are_unique_for_rapid_consecutive_construction() {
|
|
||||||
let first = Commitment::new_manual(
|
|
||||||
"Implement first action",
|
|
||||||
"first tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let second = Commitment::new_manual(
|
|
||||||
"Implement second action",
|
|
||||||
"second tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_ne!(first.id, second.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() {
|
|
||||||
let first = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
let second = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_ne!(first.id, second.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_serializes_enum_contract_as_snake_case() {
|
|
||||||
let commitment = Commitment::new_manual(
|
|
||||||
"Implement domain contracts",
|
|
||||||
"domain tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&commitment).unwrap();
|
|
||||||
|
|
||||||
assert!(json.contains(r#""source":"manual""#));
|
|
||||||
assert!(json.contains(r#""state":"draft""#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_serializes_enum_contract_as_snake_case() {
|
|
||||||
let policy = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&policy).unwrap();
|
|
||||||
|
|
||||||
assert!(json.contains(r#""runtime_state":"active""#));
|
|
||||||
assert!(json.contains(r#""enforcement_level":"warn""#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_validate_rejects_invalid_public_field_values() {
|
|
||||||
let mut commitment =
|
|
||||||
Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500))
|
|
||||||
.unwrap();
|
|
||||||
commitment.id = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingId)
|
|
||||||
);
|
|
||||||
|
|
||||||
commitment.id = "commitment-1".to_string();
|
|
||||||
commitment.next_action = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingNextAction)
|
|
||||||
);
|
|
||||||
|
|
||||||
commitment.next_action = "Refactor state".to_string();
|
|
||||||
commitment.success_condition = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingSuccessCondition)
|
|
||||||
);
|
|
||||||
|
|
||||||
commitment.success_condition = "tests pass".to_string();
|
|
||||||
commitment.timebox_secs = 0;
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingTimebox)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_validate_rejects_invalid_deserialized_values() {
|
|
||||||
let json = r#"{
|
|
||||||
"id": "",
|
|
||||||
"created_at_unix_secs": 1,
|
|
||||||
"source": "manual",
|
|
||||||
"project_id": null,
|
|
||||||
"template_id": null,
|
|
||||||
"next_action": "Refactor state",
|
|
||||||
"success_condition": "tests pass",
|
|
||||||
"timebox_secs": 1500,
|
|
||||||
"transition_policy_id": null,
|
|
||||||
"state": "draft"
|
|
||||||
}"#;
|
|
||||||
let commitment: Commitment = serde_json::from_str(json).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validation_errors_implement_std_error() {
|
|
||||||
fn assert_std_error<E: std::error::Error>() {}
|
|
||||||
|
|
||||||
assert_std_error::<CommitmentValidationError>();
|
|
||||||
assert_std_error::<PolicySnapshotValidationError>();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_try_for_runtime_rejects_empty_commitment_id() {
|
|
||||||
let err = PolicySnapshot::try_for_runtime(
|
|
||||||
" ".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
)
|
|
||||||
.expect_err("empty commitment id must be rejected");
|
|
||||||
|
|
||||||
assert_eq!(err, PolicySnapshotValidationError::MissingCommitmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_validate_rejects_invalid_public_field_values() {
|
|
||||||
let mut policy = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
policy.schema_version = POLICY_SCHEMA_VERSION + 1;
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::WrongSchemaVersion)
|
|
||||||
);
|
|
||||||
|
|
||||||
policy.schema_version = POLICY_SCHEMA_VERSION;
|
|
||||||
policy.id = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingId)
|
|
||||||
);
|
|
||||||
|
|
||||||
policy.id = "policy-1".to_string();
|
|
||||||
policy.commitment_id = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingCommitmentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
policy.commitment_id = "commitment-1".to_string();
|
|
||||||
policy.generated_by_agent_version = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_validate_rejects_invalid_deserialized_values() {
|
|
||||||
let json = r#"{
|
|
||||||
"id": "",
|
|
||||||
"commitment_id": "commitment-1",
|
|
||||||
"schema_version": 1,
|
|
||||||
"created_at_unix_secs": 1,
|
|
||||||
"runtime_state": "active",
|
|
||||||
"enforcement_level": "warn",
|
|
||||||
"allowed_context": {
|
|
||||||
"window_classes": [],
|
|
||||||
"window_title_substrings": [],
|
|
||||||
"domains": [],
|
|
||||||
"repos": [],
|
|
||||||
"commands": []
|
|
||||||
},
|
|
||||||
"required_monitors": [],
|
|
||||||
"violation_actions": [],
|
|
||||||
"expires_at_unix_secs": null,
|
|
||||||
"generated_by_agent_version": "0.1.0"
|
|
||||||
}"#;
|
|
||||||
let policy: PolicySnapshot = serde_json::from_str(json).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,583 +0,0 @@
|
|||||||
use crate::domain::{unix_secs_now, RuntimeState, POLICY_SCHEMA_VERSION};
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use fs2::FileExt;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::fs::{self, File, OpenOptions};
|
|
||||||
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EventType {
|
|
||||||
CommitmentCreated,
|
|
||||||
RuntimeTransition,
|
|
||||||
CommitmentTransition,
|
|
||||||
PolicyApplied,
|
|
||||||
EvidenceObserved,
|
|
||||||
ViolationObserved,
|
|
||||||
TransitionStarted,
|
|
||||||
ReviewCompleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct EventRecord {
|
|
||||||
pub schema_version: u16,
|
|
||||||
pub sequence: u64,
|
|
||||||
pub timestamp_unix_secs: u64,
|
|
||||||
pub event_type: EventType,
|
|
||||||
pub commitment_id: Option<String>,
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub payload_json: Value,
|
|
||||||
pub previous_hash: Option<String>,
|
|
||||||
pub hash: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct PendingEventRecord {
|
|
||||||
pub event_type: EventType,
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub commitment_id: Option<String>,
|
|
||||||
pub payload_json: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct EventLog {
|
|
||||||
path: PathBuf,
|
|
||||||
next_sequence: u64,
|
|
||||||
previous_hash: Option<String>,
|
|
||||||
needs_parent_sync_after_append: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLog {
|
|
||||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = path.as_ref().to_path_buf();
|
|
||||||
create_parent_dirs(&path)?;
|
|
||||||
|
|
||||||
let (mut file, created) = open_log_file(&path)?;
|
|
||||||
file.lock_shared()
|
|
||||||
.with_context(|| format!("lock event log for read {}", path.display()))?;
|
|
||||||
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &path)?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
path,
|
|
||||||
next_sequence,
|
|
||||||
previous_hash,
|
|
||||||
needs_parent_sync_after_append: created,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn append(
|
|
||||||
&mut self,
|
|
||||||
event_type: EventType,
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
commitment_id: Option<String>,
|
|
||||||
payload_json: Value,
|
|
||||||
) -> Result<EventRecord> {
|
|
||||||
let mut records = self.append_batch([PendingEventRecord {
|
|
||||||
event_type,
|
|
||||||
runtime_state,
|
|
||||||
commitment_id,
|
|
||||||
payload_json,
|
|
||||||
}])?;
|
|
||||||
records
|
|
||||||
.pop()
|
|
||||||
.ok_or_else(|| anyhow!("single event append produced no record"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn append_batch(
|
|
||||||
&mut self,
|
|
||||||
events: impl IntoIterator<Item = PendingEventRecord>,
|
|
||||||
) -> Result<Vec<EventRecord>> {
|
|
||||||
let events: Vec<PendingEventRecord> = events.into_iter().collect();
|
|
||||||
if events.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
create_parent_dirs(&self.path)?;
|
|
||||||
|
|
||||||
let existed = self
|
|
||||||
.path
|
|
||||||
.try_exists()
|
|
||||||
.with_context(|| format!("check whether event log exists {}", self.path.display()))?;
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&self.path)
|
|
||||||
.with_context(|| format!("open event log for append {}", self.path.display()))?;
|
|
||||||
file.lock_exclusive()
|
|
||||||
.with_context(|| format!("lock event log {}", self.path.display()))?;
|
|
||||||
|
|
||||||
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &self.path)?;
|
|
||||||
|
|
||||||
let mut records = Vec::with_capacity(events.len());
|
|
||||||
let mut sequence = next_sequence;
|
|
||||||
let mut record_previous_hash = previous_hash;
|
|
||||||
for event in events {
|
|
||||||
let mut record = EventRecord {
|
|
||||||
schema_version: POLICY_SCHEMA_VERSION,
|
|
||||||
sequence,
|
|
||||||
timestamp_unix_secs: unix_secs_now(),
|
|
||||||
event_type: event.event_type,
|
|
||||||
commitment_id: event.commitment_id,
|
|
||||||
runtime_state: event.runtime_state,
|
|
||||||
payload_json: event.payload_json,
|
|
||||||
previous_hash: record_previous_hash,
|
|
||||||
hash: String::new(),
|
|
||||||
};
|
|
||||||
record.hash = record_hash(&record)?;
|
|
||||||
record_previous_hash = Some(record.hash.clone());
|
|
||||||
sequence += 1;
|
|
||||||
records.push(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
for record in &records {
|
|
||||||
serde_json::to_writer(&mut bytes, record).with_context(|| {
|
|
||||||
format!("serialize event log record to {}", self.path.display())
|
|
||||||
})?;
|
|
||||||
bytes.push(b'\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
file.write_all(&bytes).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"write {} event log records to {}",
|
|
||||||
records.len(),
|
|
||||||
self.path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
file.flush()
|
|
||||||
.with_context(|| format!("flush event log {}", self.path.display()))?;
|
|
||||||
file.sync_data()
|
|
||||||
.with_context(|| format!("sync event log {}", self.path.display()))?;
|
|
||||||
if !existed || self.needs_parent_sync_after_append {
|
|
||||||
sync_parent_dir(&self.path)?;
|
|
||||||
self.needs_parent_sync_after_append = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.next_sequence = sequence;
|
|
||||||
self.previous_hash = record_previous_hash;
|
|
||||||
|
|
||||||
Ok(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn records(&self) -> Result<Vec<EventRecord>> {
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.open(&self.path)
|
|
||||||
.with_context(|| format!("open event log for read {}", self.path.display()))?;
|
|
||||||
file.lock_shared()
|
|
||||||
.with_context(|| format!("lock event log for read {}", self.path.display()))?;
|
|
||||||
load_records_locked(&mut file, &self.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_parent_dirs(path: &Path) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).with_context(|| {
|
|
||||||
format!("create parent directories for event log {}", path.display())
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_log_file(path: &Path) -> Result<(File, bool)> {
|
|
||||||
let existed = path
|
|
||||||
.try_exists()
|
|
||||||
.with_context(|| format!("check whether event log exists {}", path.display()))?;
|
|
||||||
let file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.append(true)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("open event log {}", path.display()))?;
|
|
||||||
Ok((file, !existed))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_tail_locked(file: &mut File, path: &Path) -> Result<(u64, Option<String>)> {
|
|
||||||
let records = load_records_locked(file, path)?;
|
|
||||||
let next_sequence = records.last().map_or(1, |record| record.sequence + 1);
|
|
||||||
let previous_hash = records.last().map(|record| record.hash.clone());
|
|
||||||
Ok((next_sequence, previous_hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_records_locked(file: &mut File, path: &Path) -> Result<Vec<EventRecord>> {
|
|
||||||
let mut next_sequence = 1;
|
|
||||||
let mut previous_hash = None;
|
|
||||||
let mut records = Vec::new();
|
|
||||||
|
|
||||||
file.seek(SeekFrom::Start(0))
|
|
||||||
.with_context(|| format!("seek event log {}", path.display()))?;
|
|
||||||
for (line_index, line) in BufReader::new(file).lines().enumerate() {
|
|
||||||
let line_number = line_index + 1;
|
|
||||||
let line = line.with_context(|| {
|
|
||||||
format!("read event log line {line_number} from {}", path.display())
|
|
||||||
})?;
|
|
||||||
if line.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let record: EventRecord = serde_json::from_str(&line).with_context(|| {
|
|
||||||
format!("parse event log line {line_number} from {}", path.display())
|
|
||||||
})?;
|
|
||||||
validate_record(&record, next_sequence, previous_hash.as_deref())
|
|
||||||
.map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?;
|
|
||||||
|
|
||||||
next_sequence += 1;
|
|
||||||
previous_hash = Some(record.hash.clone());
|
|
||||||
records.push(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn sync_parent_dir(path: &Path) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
let dir = File::open(parent)
|
|
||||||
.with_context(|| format!("open parent directory for event log {}", path.display()))?;
|
|
||||||
dir.sync_all().with_context(|| {
|
|
||||||
format!(
|
|
||||||
"sync parent directory {} for event log {}",
|
|
||||||
parent.display(),
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn sync_parent_dir(_path: &Path) -> Result<()> {
|
|
||||||
// Directory fsync is not exposed portably by std on all supported platforms.
|
|
||||||
// Linux and other Unix targets take the durable path above.
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_record(
|
|
||||||
record: &EventRecord,
|
|
||||||
expected_sequence: u64,
|
|
||||||
expected_previous_hash: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if record.schema_version != POLICY_SCHEMA_VERSION {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"unsupported schema version at sequence {}: expected {}, found {}",
|
|
||||||
record.sequence,
|
|
||||||
POLICY_SCHEMA_VERSION,
|
|
||||||
record.schema_version
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if record.sequence != expected_sequence {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"sequence mismatch: expected {}, found {}",
|
|
||||||
expected_sequence,
|
|
||||||
record.sequence
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if record.previous_hash.as_deref() != expected_previous_hash {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"previous hash mismatch at sequence {}",
|
|
||||||
record.sequence
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expected_hash = record_hash(record)?;
|
|
||||||
if record.hash != expected_hash {
|
|
||||||
return Err(anyhow!("hash mismatch at sequence {}", record.sequence));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_hash(record: &EventRecord) -> Result<String> {
|
|
||||||
let mut hashable = record.clone();
|
|
||||||
hashable.hash.clear();
|
|
||||||
let bytes = serde_json::to_vec(&hashable).context("serialize event log record for hash")?;
|
|
||||||
Ok(hex::encode(Sha256::digest(bytes)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::domain::RuntimeState;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_log_path(name: &str) -> PathBuf {
|
|
||||||
let nonce = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"antidrift-event-log-{name}-{}-{nonce}.jsonl",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn appends_hash_chained_jsonl_records() {
|
|
||||||
let path = temp_log_path("append");
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
let first = log
|
|
||||||
.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
Some("commitment-1".to_string()),
|
|
||||||
json!({"from": "active", "to": "transition"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let second = log
|
|
||||||
.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
Some("commitment-1".to_string()),
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(first.sequence, 1);
|
|
||||||
assert_eq!(second.sequence, 2);
|
|
||||||
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
|
|
||||||
assert_ne!(first.hash, second.hash);
|
|
||||||
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(contents.lines().count(), 2);
|
|
||||||
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_appends_hash_chained_adjacent_records() {
|
|
||||||
let path = temp_log_path("batch");
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
let records = log
|
|
||||||
.append_batch([
|
|
||||||
PendingEventRecord {
|
|
||||||
event_type: EventType::CommitmentCreated,
|
|
||||||
runtime_state: RuntimeState::Active,
|
|
||||||
commitment_id: Some("commitment-1".to_string()),
|
|
||||||
payload_json: json!({"commitment_id": "commitment-1"}),
|
|
||||||
},
|
|
||||||
PendingEventRecord {
|
|
||||||
event_type: EventType::PolicyApplied,
|
|
||||||
runtime_state: RuntimeState::Active,
|
|
||||||
commitment_id: Some("commitment-1".to_string()),
|
|
||||||
payload_json: json!({"policy_id": "policy-1"}),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(records.len(), 2);
|
|
||||||
assert_eq!(records[0].sequence, 1);
|
|
||||||
assert_eq!(records[1].sequence, 2);
|
|
||||||
assert_eq!(
|
|
||||||
records[1].previous_hash.as_deref(),
|
|
||||||
Some(records[0].hash.as_str())
|
|
||||||
);
|
|
||||||
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(contents.lines().count(), 2);
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_existing_log_validates_chain_and_continues_sequence() {
|
|
||||||
let path = temp_log_path("reopen");
|
|
||||||
{
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut reopened = EventLog::open(&path).unwrap();
|
|
||||||
let next = reopened
|
|
||||||
.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
None,
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(next.sequence, 2);
|
|
||||||
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_missing_log_creates_reopenable_file() {
|
|
||||||
let path = temp_log_path("open-missing");
|
|
||||||
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
assert!(path.exists());
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_malformed_json_log_fails() {
|
|
||||||
let path = temp_log_path("malformed");
|
|
||||||
fs::write(&path, "{not json}\n").unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("malformed JSONL must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("parse event log"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_tampered_log_chain_fails() {
|
|
||||||
let path = temp_log_path("tampered");
|
|
||||||
{
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
None,
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
fs::write(&path, contents.replace("\"sequence\":2", "\"sequence\":3")).unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("tampered chain must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("sequence mismatch"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_log_with_unknown_record_field_fails() {
|
|
||||||
let path = temp_log_path("unknown-field");
|
|
||||||
{
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
let mut record_json: Value =
|
|
||||||
serde_json::from_str(contents.lines().next().unwrap()).unwrap();
|
|
||||||
record_json["unexpected_field"] = json!("ignored-before-hashing");
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!("{}\n", serde_json::to_string(&record_json).unwrap()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("unknown record fields must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("parse event log"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_log_with_unsupported_schema_version_fails() {
|
|
||||||
let path = temp_log_path("unsupported-schema");
|
|
||||||
let mut record = EventRecord {
|
|
||||||
schema_version: 999,
|
|
||||||
sequence: 1,
|
|
||||||
timestamp_unix_secs: unix_secs_now(),
|
|
||||||
event_type: EventType::RuntimeTransition,
|
|
||||||
commitment_id: None,
|
|
||||||
runtime_state: RuntimeState::Transition,
|
|
||||||
payload_json: json!({"from": "active"}),
|
|
||||||
previous_hash: None,
|
|
||||||
hash: String::new(),
|
|
||||||
};
|
|
||||||
record.hash = record_hash(&record).unwrap();
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!("{}\n", serde_json::to_string(&record).unwrap()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("unsupported schema version must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("unsupported schema version"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_handle_reloads_tail_before_append() {
|
|
||||||
let path = temp_log_path("stale-handle");
|
|
||||||
let mut log1 = EventLog::open(&path).unwrap();
|
|
||||||
let mut log2 = EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
let first = log1
|
|
||||||
.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let second = log2
|
|
||||||
.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
None,
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(second.sequence, 2);
|
|
||||||
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_creates_parent_dirs_and_writes_reopenable_file() {
|
|
||||||
let path = temp_log_path("append-parents")
|
|
||||||
.with_file_name("nested")
|
|
||||||
.join("events.jsonl");
|
|
||||||
let mut log = EventLog {
|
|
||||||
path: path.clone(),
|
|
||||||
next_sequence: 1,
|
|
||||||
previous_hash: None,
|
|
||||||
needs_parent_sync_after_append: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let record = log
|
|
||||||
.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(record.sequence, 1);
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
fs::remove_file(&path).unwrap();
|
|
||||||
fs::remove_dir(path.parent().unwrap()).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod context;
|
|
||||||
pub mod domain;
|
|
||||||
pub mod event_log;
|
|
||||||
pub mod session;
|
|
||||||
pub mod state_machine;
|
|
||||||
pub mod window;
|
|
||||||
-2121
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,327 +0,0 @@
|
|||||||
use crate::domain::{CommitmentState, RuntimeState};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum RuntimeAction {
|
|
||||||
EnterPlanning,
|
|
||||||
CancelOrTimeout,
|
|
||||||
Activate { policy_accepted: bool },
|
|
||||||
StartTransition,
|
|
||||||
CompleteForReview,
|
|
||||||
SevereViolation,
|
|
||||||
ReturnToActive,
|
|
||||||
SwitchTask,
|
|
||||||
EndTransitionForReview,
|
|
||||||
ContinuePlanning,
|
|
||||||
EndWorkPeriod,
|
|
||||||
EnterAdminOverride,
|
|
||||||
ExitAdminOverride(RuntimeState),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum CommitmentAction {
|
|
||||||
Activate,
|
|
||||||
PauseForTransition,
|
|
||||||
ReturnFromPause,
|
|
||||||
Complete,
|
|
||||||
Abandon,
|
|
||||||
Violate,
|
|
||||||
RecoverExplicitly,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum TransitionError {
|
|
||||||
PolicyRejected,
|
|
||||||
IllegalRuntimeTransition {
|
|
||||||
current: RuntimeState,
|
|
||||||
action: RuntimeAction,
|
|
||||||
},
|
|
||||||
IllegalCommitmentTransition {
|
|
||||||
current: CommitmentState,
|
|
||||||
action: CommitmentAction,
|
|
||||||
},
|
|
||||||
InvalidAdminOverrideExitTarget {
|
|
||||||
target: RuntimeState,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for TransitionError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
TransitionError::PolicyRejected => write!(f, "policy was rejected"),
|
|
||||||
TransitionError::IllegalRuntimeTransition { current, action } => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"illegal runtime transition from {:?} via {:?}",
|
|
||||||
current, action
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TransitionError::IllegalCommitmentTransition { current, action } => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"illegal commitment transition from {:?} via {:?}",
|
|
||||||
current, action
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TransitionError::InvalidAdminOverrideExitTarget { target } => {
|
|
||||||
write!(f, "invalid admin override exit target {:?}", target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for TransitionError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CompositeTransition {
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub commitment_state: CommitmentState,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn transition_runtime(
|
|
||||||
current: RuntimeState,
|
|
||||||
action: RuntimeAction,
|
|
||||||
) -> Result<RuntimeState, TransitionError> {
|
|
||||||
use RuntimeAction::*;
|
|
||||||
use RuntimeState::*;
|
|
||||||
|
|
||||||
match (current, action) {
|
|
||||||
(Locked, EnterPlanning) => Ok(Planning),
|
|
||||||
(
|
|
||||||
Planning,
|
|
||||||
Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
},
|
|
||||||
) => Ok(Active),
|
|
||||||
(
|
|
||||||
Planning,
|
|
||||||
Activate {
|
|
||||||
policy_accepted: false,
|
|
||||||
},
|
|
||||||
) => Err(TransitionError::PolicyRejected),
|
|
||||||
(Planning, CancelOrTimeout) => Ok(Locked),
|
|
||||||
(Active, StartTransition) => Ok(Transition),
|
|
||||||
(Active, CompleteForReview) => Ok(Review),
|
|
||||||
(Active, SevereViolation) => Ok(Locked),
|
|
||||||
(Transition, ReturnToActive) => Ok(Active),
|
|
||||||
(Transition, SwitchTask) => Ok(Planning),
|
|
||||||
(Transition, EndTransitionForReview) => Ok(Review),
|
|
||||||
(Transition, CancelOrTimeout) => Ok(Locked),
|
|
||||||
(Review, ContinuePlanning) => Ok(Planning),
|
|
||||||
(Review, EndWorkPeriod) => Ok(Locked),
|
|
||||||
(_, EnterAdminOverride) => Ok(AdminOverride),
|
|
||||||
(AdminOverride, ExitAdminOverride(Locked)) => Ok(Locked),
|
|
||||||
(AdminOverride, ExitAdminOverride(Planning)) => Ok(Planning),
|
|
||||||
(AdminOverride, ExitAdminOverride(target)) => {
|
|
||||||
Err(TransitionError::InvalidAdminOverrideExitTarget { target })
|
|
||||||
}
|
|
||||||
_ => Err(TransitionError::IllegalRuntimeTransition { current, action }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn transition_commitment(
|
|
||||||
current: CommitmentState,
|
|
||||||
action: CommitmentAction,
|
|
||||||
) -> Result<CommitmentState, TransitionError> {
|
|
||||||
use CommitmentAction::*;
|
|
||||||
use CommitmentState::*;
|
|
||||||
|
|
||||||
match (current.clone(), action) {
|
|
||||||
(Draft, Activate) => Ok(Active),
|
|
||||||
(Active, PauseForTransition) => Ok(Paused),
|
|
||||||
(Paused, ReturnFromPause) => Ok(Active),
|
|
||||||
(Active, Complete) => Ok(Completed),
|
|
||||||
(Active, Abandon) => Ok(Abandoned),
|
|
||||||
(Active, Violate) => Ok(Violated),
|
|
||||||
(Paused, Abandon) => Ok(Abandoned),
|
|
||||||
(Violated, RecoverExplicitly) => Ok(Active),
|
|
||||||
(Violated, Abandon) => Ok(Abandoned),
|
|
||||||
_ => Err(TransitionError::IllegalCommitmentTransition { current, action }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_transition(
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
commitment_state: CommitmentState,
|
|
||||||
) -> Result<CompositeTransition, TransitionError> {
|
|
||||||
let next_runtime = transition_runtime(runtime_state, RuntimeAction::StartTransition)?;
|
|
||||||
let next_commitment =
|
|
||||||
transition_commitment(commitment_state, CommitmentAction::PauseForTransition)?;
|
|
||||||
|
|
||||||
Ok(CompositeTransition {
|
|
||||||
runtime_state: next_runtime,
|
|
||||||
commitment_state: next_commitment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn planning_can_only_activate_with_valid_policy() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::Planning,
|
|
||||||
RuntimeAction::Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Ok(RuntimeState::Active)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::Planning,
|
|
||||||
RuntimeAction::Activate {
|
|
||||||
policy_accepted: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Err(TransitionError::PolicyRejected)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn active_can_enter_transition_or_review_or_locked() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(RuntimeState::Active, RuntimeAction::StartTransition),
|
|
||||||
Ok(RuntimeState::Transition)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(RuntimeState::Active, RuntimeAction::CompleteForReview),
|
|
||||||
Ok(RuntimeState::Review)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(RuntimeState::Active, RuntimeAction::SevereViolation),
|
|
||||||
Ok(RuntimeState::Locked)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn locked_cannot_directly_become_active() {
|
|
||||||
let err = transition_runtime(
|
|
||||||
RuntimeState::Locked,
|
|
||||||
RuntimeAction::Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::IllegalRuntimeTransition {
|
|
||||||
current: RuntimeState::Locked,
|
|
||||||
action: RuntimeAction::Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
err.to_string(),
|
|
||||||
"illegal runtime transition from Locked via Activate { policy_accepted: true }"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn admin_override_cannot_exit_to_admin_override() {
|
|
||||||
let err = transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(RuntimeState::AdminOverride),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::InvalidAdminOverrideExitTarget {
|
|
||||||
target: RuntimeState::AdminOverride,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn admin_override_cannot_exit_to_active_or_transition() {
|
|
||||||
for target in [RuntimeState::Active, RuntimeState::Transition] {
|
|
||||||
let err = transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(target),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::InvalidAdminOverrideExitTarget { target }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn admin_override_can_exit_to_locked_or_planning() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(RuntimeState::Locked),
|
|
||||||
),
|
|
||||||
Ok(RuntimeState::Locked)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(RuntimeState::Planning),
|
|
||||||
),
|
|
||||||
Ok(RuntimeState::Planning)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_violation_recovery_is_explicit() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_commitment(
|
|
||||||
CommitmentState::Violated,
|
|
||||||
CommitmentAction::RecoverExplicitly
|
|
||||||
),
|
|
||||||
Ok(CommitmentState::Active)
|
|
||||||
);
|
|
||||||
let err =
|
|
||||||
transition_commitment(CommitmentState::Violated, CommitmentAction::ReturnFromPause)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::IllegalCommitmentTransition {
|
|
||||||
current: CommitmentState::Violated,
|
|
||||||
action: CommitmentAction::ReturnFromPause,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
err.to_string(),
|
|
||||||
"illegal commitment transition from Violated via ReturnFromPause"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn start_transition_moves_runtime_and_commitment_together() {
|
|
||||||
assert_eq!(
|
|
||||||
start_transition(RuntimeState::Active, CommitmentState::Active),
|
|
||||||
Ok(CompositeTransition {
|
|
||||||
runtime_state: RuntimeState::Transition,
|
|
||||||
commitment_state: CommitmentState::Paused,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn start_transition_fails_before_returning_partial_state() {
|
|
||||||
assert_eq!(
|
|
||||||
start_transition(RuntimeState::Active, CommitmentState::Draft),
|
|
||||||
Err(TransitionError::IllegalCommitmentTransition {
|
|
||||||
current: CommitmentState::Draft,
|
|
||||||
action: CommitmentAction::PauseForTransition,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
start_transition(RuntimeState::Locked, CommitmentState::Active),
|
|
||||||
Err(TransitionError::IllegalRuntimeTransition {
|
|
||||||
current: RuntimeState::Locked,
|
|
||||||
action: RuntimeAction::StartTransition,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
use super::WindowSnapshot;
|
|
||||||
use crate::domain::EvidenceHealth;
|
|
||||||
use regex::Regex;
|
|
||||||
use std::{process::Command, process::Output, str};
|
|
||||||
|
|
||||||
pub fn get_title_clean() -> String {
|
|
||||||
get_snapshot().title
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_snapshot() -> WindowSnapshot {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
|
|
||||||
let title = re.replace_all(&window_info.title, "").to_string();
|
|
||||||
let class = (window_info.class != "none").then_some(window_info.class);
|
|
||||||
let health = if window_info.wid.is_empty() {
|
|
||||||
EvidenceHealth::Unavailable("xdotool active window unavailable".to_string())
|
|
||||||
} else {
|
|
||||||
EvidenceHealth::Available
|
|
||||||
};
|
|
||||||
|
|
||||||
WindowSnapshot {
|
|
||||||
title,
|
|
||||||
class,
|
|
||||||
health,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn minimize_other(title: &str) {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
if window_info.wid.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if window_info.title != title {
|
|
||||||
run_xdotool(&["windowminimize", &window_info.wid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WindowInfo {
|
|
||||||
title: String,
|
|
||||||
class: String,
|
|
||||||
wid: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_window_id(wid: &str) -> bool {
|
|
||||||
!wid.is_empty() && wid.bytes().all(|byte| byte.is_ascii_digit())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_xdotool(args: &[&str]) -> Option<String> {
|
|
||||||
let output = Command::new("xdotool").args(args).output();
|
|
||||||
|
|
||||||
let Ok(Output {
|
|
||||||
status,
|
|
||||||
stdout,
|
|
||||||
stderr: _,
|
|
||||||
}) = output
|
|
||||||
else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
if status.code() != Some(0) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(output_str) = str::from_utf8(&stdout) else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(output_str.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_window_info() -> WindowInfo {
|
|
||||||
let none = WindowInfo {
|
|
||||||
title: "none".to_string(),
|
|
||||||
class: "none".to_string(),
|
|
||||||
wid: "".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(wid) = run_xdotool(&["getactivewindow"]) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !is_valid_window_id(&wid) {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(class) = run_xdotool(&["getwindowclassname", &wid]) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(title) = run_xdotool(&["getwindowname", &wid]) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
WindowInfo { title, class, wid }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn valid_window_id_accepts_ascii_digits_only() {
|
|
||||||
assert!(is_valid_window_id("12345"));
|
|
||||||
assert!(is_valid_window_id("0"));
|
|
||||||
|
|
||||||
assert!(!is_valid_window_id(""));
|
|
||||||
assert!(!is_valid_window_id("12abc"));
|
|
||||||
assert!(!is_valid_window_id(" 123"));
|
|
||||||
assert!(!is_valid_window_id("123 "));
|
|
||||||
assert!(!is_valid_window_id("123"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use crate::domain::EvidenceHealth;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct WindowSnapshot {
|
|
||||||
pub title: String,
|
|
||||||
pub class: Option<String>,
|
|
||||||
pub health: EvidenceHealth,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowSnapshot {
|
|
||||||
pub fn unavailable(reason: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: "none".to_string(),
|
|
||||||
class: None,
|
|
||||||
health: EvidenceHealth::Unavailable(reason.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
mod windows;
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub use windows::*;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod linux;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub use linux::*;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unavailable_snapshot_records_reason_without_evidence() {
|
|
||||||
assert_eq!(
|
|
||||||
WindowSnapshot::unavailable("no active window"),
|
|
||||||
WindowSnapshot {
|
|
||||||
title: "none".to_string(),
|
|
||||||
class: None,
|
|
||||||
health: EvidenceHealth::Unavailable("no active window".to_string()),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
use super::WindowSnapshot;
|
|
||||||
use crate::domain::EvidenceHealth;
|
|
||||||
use regex::Regex;
|
|
||||||
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
|
||||||
use winapi::shared::windef::HWND;
|
|
||||||
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
|
|
||||||
|
|
||||||
pub fn get_title_clean() -> String {
|
|
||||||
get_snapshot().title
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_snapshot() -> WindowSnapshot {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
if window_info.hwnd.is_null() {
|
|
||||||
return WindowSnapshot::unavailable("foreground window unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
|
|
||||||
let title = re.replace_all(&window_info.title, "").to_string();
|
|
||||||
let health = if window_info.title.is_empty() {
|
|
||||||
EvidenceHealth::Degraded("foreground window title unavailable".to_string())
|
|
||||||
} else {
|
|
||||||
EvidenceHealth::Degraded("window class unavailable on current Windows adapter".to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
WindowSnapshot {
|
|
||||||
title,
|
|
||||||
class: None,
|
|
||||||
health,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn minimize_other(title: &str) {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
if window_info.hwnd.is_null() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if window_info.title != title {
|
|
||||||
unsafe {
|
|
||||||
ShowWindow(window_info.hwnd, SW_MINIMIZE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WindowInfo {
|
|
||||||
title: String,
|
|
||||||
hwnd: HWND,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_window_info() -> WindowInfo {
|
|
||||||
unsafe {
|
|
||||||
let hwnd = GetForegroundWindow();
|
|
||||||
if hwnd.is_null() {
|
|
||||||
return WindowInfo {
|
|
||||||
title: String::new(),
|
|
||||||
hwnd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut text: [u16; 512] = [0; 512];
|
|
||||||
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
|
|
||||||
let title = OsString::from_wide(&text[..len])
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
WindowInfo { title, hwnd }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user