Compare commits
137 Commits
571a97e0e2
...
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 | |||
| 14ec2b6ea8 | |||
| b447432df0 | |||
| ca5e76baf5 | |||
| e5c32563d0 | |||
| 7eb2b5dc58 | |||
| 6b99804132 | |||
| 38e1a175b6 | |||
| ce201c57bb | |||
| 326990d69a | |||
| 324aa3ebce | |||
| 86c85629c0 | |||
| acf336c846 | |||
| a108964457 | |||
| 1a925b3a05 | |||
| 2a196bcdb2 | |||
| e3351b3d70 | |||
| ae5cec3dce | |||
| 3fdbfd307b | |||
| 4f6b26a220 | |||
| 71b8c80c69 | |||
| 1c81eb1b50 | |||
| d95aed0111 | |||
| 895ad342c7 | |||
| 59283be733 | |||
| fbbff6966d | |||
| db0d11ea2e | |||
| d3f0526355 | |||
| 0d3294b30d | |||
| d068e2f79c | |||
| a0f144968b | |||
| f13f33cf74 | |||
| 465938906e | |||
| 79b3c0be95 | |||
| 5adab985a7 | |||
| 4cc1edfc60 | |||
| 47f1167247 | |||
| 41c0a5fbbc | |||
| 7fdcae5d14 | |||
| d0e6893659 | |||
| 76cdd4c857 | |||
| e3cfe8c70c | |||
| 95e14e6340 | |||
| ff100b2472 | |||
| a4f0c00cac | |||
| 25f56b3c71 | |||
| e4e34b092c | |||
| 82fd7d35a5 | |||
| f417faab9a | |||
| 5342da2755 | |||
| b1b807590c | |||
| 8bd37ed46d | |||
| 0f1790c8d5 | |||
| 09061f8e30 | |||
| 9e03add01b | |||
| 9452e54867 | |||
| 8b5121c69d | |||
| e832a2da85 | |||
| c4eb870808 | |||
| 8b7bec1173 | |||
| 569264be11 | |||
| 267eda59e6 | |||
| 6b2a96620c | |||
| 51cb09c520 | |||
| f5da12362a | |||
| 76816ae188 | |||
| 93e779348c | |||
| c807a72623 | |||
| 097678e839 | |||
| 19654c2b57 | |||
| 622a1cd401 | |||
| 9dd0bb934e | |||
| 20a102b4a4 | |||
| 97577c003f | |||
| b43ba5179d | |||
| b96319d847 | |||
| 4e5e1f3881 | |||
| 5818d401c3 | |||
| 7621093eef | |||
| fefa72b909 | |||
| f73bb21c80 | |||
| 6ca796dee3 | |||
| 4e7676560d | |||
| 2977b903c2 | |||
| 75d31b5fa8 | |||
| 83bae00e45 | |||
| 7e9d00b80b | |||
| fccf856054 | |||
| 4e167744a0 |
+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
|
||||||
|
|
||||||
@@ -23,14 +49,59 @@ go test ./...
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
**M8 (Tier A) — Enforcement (window-minimize).** Drift finally costs something.
|
||||||
|
A planning-screen "Enforce focus" toggle arms the new `enforce.Guard` port: when
|
||||||
|
the drift judge confirms the active window is off-task, the daemon minimizes that
|
||||||
|
window (native X11, no `xdotool`). It is unprivileged, per-session (the chosen
|
||||||
|
enforcement level rides the snapshot), and degrades to today's advisory behavior
|
||||||
|
when off, unwired, or on a platform without the X11 adapter.
|
||||||
|
|
||||||
|
M7 (reflection): when a session ends, a fourth AI role — the reviewer —
|
||||||
|
reflects on it, read against your recent sessions, and produces two short
|
||||||
|
lines: a recap shown on the Review screen, and a carry-forward takeaway that
|
||||||
|
grounds the coach the next time you plan. It runs once asynchronously on
|
||||||
|
entering Review, never blocks the End button, and degrades gracefully — with
|
||||||
|
no backend (or a slow/failed call) Review and Planning behave exactly as
|
||||||
|
before. The carry-forward is snapshot-persisted (latest-wins) and composes
|
||||||
|
into the coach's grounding; the reflection lines are short and cross the wire
|
||||||
|
by design, while the knowledge profile still does not.
|
||||||
|
|
||||||
|
M6 (knowledge port): the planning coach now sharpens intents against who you
|
||||||
|
actually are. A single profile file (`~/.keel/knowledge.md`, overridable
|
||||||
|
via `KEEL_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
||||||
|
values, what counts as good work — and the daemon loads it asynchronously on
|
||||||
|
entering planning, mirroring the tasks fetch. The cached text grounds the AI
|
||||||
|
coach prompt only; the drift judge and nudge are untouched, and the profile text
|
||||||
|
never crosses the wire. A subtle planning-screen indicator shows whether the
|
||||||
|
profile loaded and from where, with a "change" affordance to repoint at another
|
||||||
|
file. It degrades gracefully — a missing, blank, or unreadable file just leaves
|
||||||
|
the coach ungrounded, and planning still works.
|
||||||
|
|
||||||
|
M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
|
||||||
|
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
|
||||||
|
only while you are on-task in an allowed app — periodically reads your recent
|
||||||
|
window titles and, if the trajectory has wandered from the commitment, shows a
|
||||||
|
soft, dismissible "Heads up" line (no interrupt, no buttons to fight). It is
|
||||||
|
debounced to roughly one check every five minutes, reuses the same CLI backend
|
||||||
|
as the coach and drift judge, and degrades gracefully — without it, everything
|
||||||
|
else still works.
|
||||||
|
|
||||||
|
M3 (drift interceptor): while a commitment is Active, the daemon watches the
|
||||||
|
focused window. A cheap local match against the session's allowed window classes
|
||||||
|
is authoritative for on-task; only unmatched windows are sent to the LLM drift
|
||||||
|
judge (debounced and cached per class, run asynchronously). When it judges you
|
||||||
|
off-task, the active view shows a dismissible interrupt: "Back to task", "This is
|
||||||
|
on task" (which adds the app to the session's allowed list), or "End session".
|
||||||
|
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,76 +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"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
|
||||||
"antidrift/internal/evidence"
|
|
||||||
"antidrift/internal/session"
|
|
||||||
"antidrift/internal/store"
|
|
||||||
"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
|
|
||||||
|
|
||||||
// Wire the AI planning coach. Backend selectable via ANTIDRIFT_AI_BACKEND
|
|
||||||
// (claude default, or codex). Misconfiguration degrades to no coach rather
|
|
||||||
// than failing startup — manual planning still works.
|
|
||||||
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
|
|
||||||
log.Printf("ai coach disabled: %v", err)
|
|
||||||
} else {
|
|
||||||
ctrl.SetCoach(ai.NewService(backend))
|
|
||||||
log.Printf("ai coach: %s backend", backend.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
src := evidence.NewSource()
|
|
||||||
go src.Watch(context.Background(), ctrl.RecordWindow)
|
|
||||||
|
|
||||||
go openBrowser("http://" + addr)
|
|
||||||
|
|
||||||
log.Printf("antidriftd listening on http://%s", addr)
|
|
||||||
if err := srv.Router().Run(addr); err != nil {
|
|
||||||
log.Fatalf("server: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openBrowser best-effort launches the default browser after a short delay so
|
|
||||||
// the server is listening first. Failures are logged, not fatal.
|
|
||||||
func openBrowser(url string) {
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
var cmd string
|
|
||||||
var args []string
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "linux":
|
|
||||||
cmd, args = "xdg-open", []string{url}
|
|
||||||
case "darwin":
|
|
||||||
cmd, args = "open", []string{url}
|
|
||||||
case "windows":
|
|
||||||
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
|
||||||
default:
|
|
||||||
log.Printf("open browser manually: %s", url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := exec.Command(cmd, args...).Start(); err != nil {
|
|
||||||
log.Printf("could not open browser (%v); visit %s", err, url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Command keeld is the Keel daemon: it serves a local web UI and drives the
|
||||||
|
// harness — the general collect→brain→act loop that hosts one mode at a time
|
||||||
|
// (focus or offscreen).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/ambient"
|
||||||
|
"keel/internal/aw"
|
||||||
|
"keel/internal/enforce"
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/harness"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/memory"
|
||||||
|
"keel/internal/mode"
|
||||||
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/offscreen"
|
||||||
|
"keel/internal/notify"
|
||||||
|
"keel/internal/settings"
|
||||||
|
"keel/internal/statusfile"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
"keel/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addr = "localhost:7777"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolve home dir: %v", err)
|
||||||
|
}
|
||||||
|
root := filepath.Join(home, ".keel")
|
||||||
|
|
||||||
|
// Resolve and load settings, seeding from the legacy KEEL_* env vars on
|
||||||
|
// first run. After first run the file is the sole source of truth; env is
|
||||||
|
// ignored. A missing file is the first-run signal.
|
||||||
|
settingsPath, err := settings.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolve settings path: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := settings.Load(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
cfg = settings.SeedFromEnv()
|
||||||
|
if err := settings.Save(settingsPath, cfg); err != nil {
|
||||||
|
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("settings: seeded %s from environment", settingsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServices turns a Settings value into a fresh harness.Services. It
|
||||||
|
// validates the AI backend FIRST and returns an error before constructing any
|
||||||
|
// ports, so an invalid backend never half-rewires the harness. The same code
|
||||||
|
// path serves startup and live POST /settings edits.
|
||||||
|
buildServices := func(s settings.Settings) (harness.Services, error) {
|
||||||
|
backend, err := ai.NewBackend(s.AIBackend)
|
||||||
|
if err != nil {
|
||||||
|
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
|
||||||
|
}
|
||||||
|
svc := ai.NewService(backend)
|
||||||
|
|
||||||
|
// Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on
|
||||||
|
// first run, but Load returns whatever the JSON file holds on later starts.
|
||||||
|
awURL := s.AWURL
|
||||||
|
if awURL == "" {
|
||||||
|
awURL = "http://localhost:5600"
|
||||||
|
}
|
||||||
|
var mem memory.Store = memory.NewNop()
|
||||||
|
awc := aw.New(awURL)
|
||||||
|
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
||||||
|
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
|
||||||
|
} else {
|
||||||
|
mem = memory.NewAWStore(awc, "keel.events")
|
||||||
|
log.Printf("memory: keel.events ready at %s", awURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return harness.Services{
|
||||||
|
AI: svc,
|
||||||
|
Tasks: tasks.NewMarvin(s.MarvinCmd),
|
||||||
|
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
|
||||||
|
Enforce: enforce.NewGuard(),
|
||||||
|
Memory: mem,
|
||||||
|
Clock: time.Now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the base services at startup. A bad persisted backend should not be
|
||||||
|
// fatal: fall back to claude (always valid), persist the correction, and retry.
|
||||||
|
base, err := buildServices(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("settings: %v; falling back to claude backend", err)
|
||||||
|
cfg.AIBackend = "claude"
|
||||||
|
if err := settings.Save(settingsPath, cfg); err != nil {
|
||||||
|
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
|
||||||
|
}
|
||||||
|
base, err = buildServices(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("settings: build failed even with claude: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
|
||||||
|
log.Printf("enforce: window-minimize guard")
|
||||||
|
|
||||||
|
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
|
||||||
|
// mode builds its own file paths under Services.Dir.
|
||||||
|
withDir := func(dir string, f harness.Factory) harness.Factory {
|
||||||
|
return func(s harness.Services) mode.Mode {
|
||||||
|
s.Dir = dir
|
||||||
|
return f(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusDir := filepath.Join(root, "modes", "focus")
|
||||||
|
offscreenDir := filepath.Join(root, "modes", "offscreen")
|
||||||
|
h := harness.New(base, map[string]harness.Factory{
|
||||||
|
"focus": withDir(focusDir, focus.Factory),
|
||||||
|
"offscreen": withDir(offscreenDir, offscreen.Factory),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-adopt a live focus session restored from disk, if one was in flight.
|
||||||
|
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
|
||||||
|
// restored mode's async completions reach the harness's onChange listeners.
|
||||||
|
focusSvc := h.Services()
|
||||||
|
focusSvc.Dir = focusDir
|
||||||
|
if m, live, err := focus.Restore(focusSvc); err != nil {
|
||||||
|
log.Printf("focus: restore failed (continuing idle): %v", err)
|
||||||
|
} else if live {
|
||||||
|
if err := h.Adopt(m); err != nil {
|
||||||
|
log.Printf("focus: adopt restored session failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("focus: restored a live session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
|
||||||
|
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
|
||||||
|
// mode is active. Backend/port changes need a daemon restart to reach it; live
|
||||||
|
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
|
||||||
|
// services").
|
||||||
|
ambMode := cfg.AmbientMode
|
||||||
|
if ambMode == "" {
|
||||||
|
ambMode = settings.AmbientNotify
|
||||||
|
}
|
||||||
|
// Compute the effective cadence so the log reflects what the sentinel will
|
||||||
|
// actually use: New clamps a non-positive value (e.g. a settings.json that
|
||||||
|
// predates these fields) to a default, mirrored here for the log line.
|
||||||
|
ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second
|
||||||
|
if ambCadence <= 0 {
|
||||||
|
ambCadence = 5 * time.Minute
|
||||||
|
}
|
||||||
|
sentinel := ambient.New(ambient.Deps{
|
||||||
|
AI: base.AI,
|
||||||
|
Knowledge: base.Knowledge,
|
||||||
|
Tasks: base.Tasks,
|
||||||
|
Notifier: notify.NewNotifier(),
|
||||||
|
Memory: base.Memory,
|
||||||
|
Clock: time.Now,
|
||||||
|
ActiveMode: func() string { return h.State().ActiveMode },
|
||||||
|
}, ambCadence, ambMode)
|
||||||
|
log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence)
|
||||||
|
|
||||||
|
srv := web.NewServer(h)
|
||||||
|
srv.Init() // re-arm or expire a restored deadline-bearing session
|
||||||
|
|
||||||
|
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
|
||||||
|
sentinel.AddOnChange(srv.Broadcast)
|
||||||
|
|
||||||
|
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
|
||||||
|
// fresh harness.Services and installs it via SetServices. Per the harness
|
||||||
|
// contract this affects the NEXT mode start; an already-active mode keeps the
|
||||||
|
// services it was built with. It validates the backend before mutating, so
|
||||||
|
// POST /settings can reject an invalid backend atomically.
|
||||||
|
applyFn := func(s settings.Settings) error {
|
||||||
|
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
|
||||||
|
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
|
||||||
|
}
|
||||||
|
next, err := buildServices(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.SetServices(next)
|
||||||
|
mode := s.AmbientMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = settings.AmbientNotify
|
||||||
|
}
|
||||||
|
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
srv.SetSettings(settingsPath, cfg, applyFn)
|
||||||
|
|
||||||
|
// Mirror runtime status to ~/.keel_status for a window-manager bar.
|
||||||
|
// A misconfigured home dir degrades to no status file, not a failed start.
|
||||||
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
|
log.Printf("status file disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
|
||||||
|
h.AddOnChange(writer.Wake)
|
||||||
|
sentinel.AddOnChange(writer.Wake)
|
||||||
|
go writer.Run(context.Background())
|
||||||
|
log.Printf("status: writing %s", statusPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed the window sensor stream into BOTH the harness (for the active mode)
|
||||||
|
// and the ambient sentinel (always-on). On a headless box the source is a
|
||||||
|
// no-op, so both degrade gracefully without new X11 requirements.
|
||||||
|
src := evidence.NewSource()
|
||||||
|
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
|
||||||
|
h.RecordWindow(w)
|
||||||
|
sentinel.OnWindow(w)
|
||||||
|
})
|
||||||
|
go sentinel.Run(context.Background())
|
||||||
|
|
||||||
|
go openBrowser("http://" + addr)
|
||||||
|
|
||||||
|
log.Printf("keeld listening on http://%s", addr)
|
||||||
|
if err := srv.Router().Run(addr); err != nil {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser best-effort launches the default browser after a short delay so
|
||||||
|
// the server is listening first. Failures are logged, not fatal.
|
||||||
|
func openBrowser(url string) {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
var cmd string
|
||||||
|
var args []string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
cmd, args = "xdg-open", []string{url}
|
||||||
|
case "darwin":
|
||||||
|
cmd, args = "open", []string{url}
|
||||||
|
case "windows":
|
||||||
|
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
||||||
|
default:
|
||||||
|
log.Printf("open browser manually: %s", url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := exec.Command(cmd, args...).Start(); err != nil {
|
||||||
|
log.Printf("could not open browser (%v); visit %s", err, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
---
|
||||||
|
title: Keel — Architecture
|
||||||
|
name: keel
|
||||||
|
category: architecture
|
||||||
|
created: 2026-06-04
|
||||||
|
updated: 2026-06-04
|
||||||
|
tags:
|
||||||
|
- keel
|
||||||
|
- human-harness
|
||||||
|
- architecture
|
||||||
|
- antidrift
|
||||||
|
- activitywatch
|
||||||
|
status: authoritative
|
||||||
|
---
|
||||||
|
|
||||||
|
# Keel — Architecture
|
||||||
|
|
||||||
|
> **Name & home.** *Keel* is the system: a human-harness that helps Felix hold
|
||||||
|
> course toward his higher goals. The name answers AntiDrift's metaphor — *drift*
|
||||||
|
> is what a vessel does with nothing to hold it; the *keel* resists drift and
|
||||||
|
> keeps you on an even keel (steady mind). **This repo is `keel`** — AntiDrift's
|
||||||
|
> focus harness becomes Keel's first *mode*. The rename is **complete**: directory,
|
||||||
|
> git repo, Go module (`keel`), binary (`keeld`), runtime (`~/.keel/`), and env
|
||||||
|
> (`KEEL_*`) are all `keel`. The old `~/.antidrift/` ledger needs a one-time manual
|
||||||
|
> move to `~/.keel/` for existing installs.
|
||||||
|
> Moved from `~/dev/higher/` on 2026-06-04 — that directory was the scaffold where
|
||||||
|
> the decision to extend AntiDrift was made, and now retires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What we're building (one sentence)
|
||||||
|
|
||||||
|
**A harness** — a framework that pulls Felix's real state out of his existing
|
||||||
|
tools, hands it to a swappable brain, and turns the reply into actions and two
|
||||||
|
surfaces. The brain, the storage, and the data sources are all **reused**; the
|
||||||
|
harness is the only thing we build. The target is one system (Keel — the single
|
||||||
|
screen), grown by **loosening AntiDrift's controller**, not by starting over.
|
||||||
|
|
||||||
|
The inversion holds: this is a *human-harness*. In an agent harness a model is
|
||||||
|
the observed worker; here Felix is the observed subject and the model is the
|
||||||
|
operator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core decisions (locked 2026-06-04)
|
||||||
|
|
||||||
|
1. **The product is the harness.** The brain (`claude` / `codex` / **Hermes**) is
|
||||||
|
rented, interchangeable compute reached over a CLI or the network — *not* the
|
||||||
|
point. The value is the framework that feeds information *in* and acts on what
|
||||||
|
comes *back*. Hermes is real (an agent harness on another machine); it is one
|
||||||
|
brain option, not a dependency.
|
||||||
|
|
||||||
|
2. **Reuse AntiDrift's engine; loosen its controller.** AntiDrift already is this
|
||||||
|
harness for one plane. Its controller is welded to *commitment = next_action +
|
||||||
|
success_condition + timebox*. We loosen that into a general **collect → brain →
|
||||||
|
act** loop where a focus-session is just **one mode**. The harness now hosts a
|
||||||
|
second, **off-screen** mode; further modes (body, review, capture) are
|
||||||
|
aspirational. The core architecture is salvageable — we widen scope, we don't
|
||||||
|
rebuild.
|
||||||
|
- Worked example (the shipped non-focus mode): from the phone web UI, *"what
|
||||||
|
worthwhile off-screen thing should I do now?"* → the harness reads today's
|
||||||
|
Marvin tasks + the `~/owc` goals and life-domain `bug-*.md`, the brain
|
||||||
|
proposes one off-screen action, the UI asks Felix for feedback, and on
|
||||||
|
approval the Marvin effector files the task.
|
||||||
|
|
||||||
|
3. **Storage = ActivityWatch.** AW is already a general append-only event store
|
||||||
|
with a REST API, and it is currently the single richest *unused* asset
|
||||||
|
(~1 yr / ~287k events). Keel gets its **own AW bucket(s)** for memory. **No new
|
||||||
|
database, no new event store.** AW is therefore both a *sensor* (screen-time
|
||||||
|
ground truth) and the *store* (Keel's memory).
|
||||||
|
|
||||||
|
4. **Two surfaces, both — no fork.**
|
||||||
|
- **Web UI** — configure, interact, discuss; phone-friendly. The place Felix
|
||||||
|
*talks to* Keel.
|
||||||
|
- **WM status bar** — reflect current status (AntiDrift already writes
|
||||||
|
`~/.keel_status`). The reminder sentence lives here; it is one *status
|
||||||
|
element*, **not** a deliverable. (No "nice summaries" as a product.)
|
||||||
|
|
||||||
|
5. **Life-information root = `~/owc`** (owncloud markdown), formerly called
|
||||||
|
"clawd". Values, goals, the 22 `resources/bug-*.md`, weekly reviews,
|
||||||
|
self-management frameworks, capture stream. **Reference, never duplicate.**
|
||||||
|
|
||||||
|
6. **Effectors: start gated, trend toward autonomy.** Day one: read + *propose,
|
||||||
|
Felix confirms in the web UI*. Target: the brain does a lot on its own —
|
||||||
|
**update Beeminder datapoints, update Marvin to-dos, update `~/owc` notes.**
|
||||||
|
The gate (propose-and-confirm vs. just-do-it) is a **config surface per
|
||||||
|
effector**, not a vibe — this is the agency-vs-cage line and it stays Felix's
|
||||||
|
to set.
|
||||||
|
|
||||||
|
7. **Still in force:** don't build a new event store; AntiDrift is the substrate;
|
||||||
|
reference source-of-truth; local-first; every session ends with visible proof;
|
||||||
|
the status line stays small.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The harness (the components we build)
|
||||||
|
|
||||||
|
```
|
||||||
|
SOURCES (read) KEEL (the framework = the product) BRAIN (swappable)
|
||||||
|
───────────── ────────────────────────────────────── ──────────────────
|
||||||
|
AW :5600 ───────┐ ┌──────────────────────────────────────┐ claude (local CLI)
|
||||||
|
AntiDrift ──────┤ feed │ ① COLLECTORS → ③ ASSEMBLER → [BRAIN] │ send ──▶ codex (local CLI)
|
||||||
|
HQ hq.db ───────┤ ───────▶ │ ▲ │ │ Hermes (remote API)
|
||||||
|
Consume :8000 ──┤ │ │ ▼ │ ◀─ reply
|
||||||
|
daily.db :2200 ─┤ │ ② MEMORY = AW buckets ④ EFFECTORS │ (just compute;
|
||||||
|
Beeminder ──────┤ │ (reuse AW; no new DB) │ │ interchangeable)
|
||||||
|
Marvin ammcp ───┘ └────────────────────────────────┼─────┘
|
||||||
|
~/owc (frame) ──── grounds the brief ────────────────────────┤
|
||||||
|
⑤ renders to ──┴──────────┐
|
||||||
|
▼ ▼
|
||||||
|
WEB UI WM STATUS BAR
|
||||||
|
(configure/interact/discuss) (current status)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each component has one job, a defined interface, and can be built/tested alone:
|
||||||
|
|
||||||
|
- **① Collectors** — one small read-only adapter per source. Contract:
|
||||||
|
`read(window) -> Signal[]`. Today's real interfaces: AW `POST :5600/api/0/query/`;
|
||||||
|
AntiDrift `tail ~/.keel/audit.jsonl` (+ live SSE `:7777/events`); HQ
|
||||||
|
read-only `hq.db` (+ `:8765`); Consume `GET :8000/api/...`; daily read-only
|
||||||
|
`daily.db` (+ `:2200`); Beeminder `beeline`; Marvin `ammcp` MCP / `am --json`.
|
||||||
|
Depends only on the source being up; degrades to a dropped signal if not.
|
||||||
|
|
||||||
|
- **② Memory** — AW buckets owned by Keel. Keel writes its own derived events here
|
||||||
|
(mode_started, proposal_made, action_taken, spiral_flagged, …) so it *remembers
|
||||||
|
across runs* and can see trends a single tool can't. Rebuildable, queryable via
|
||||||
|
AW's REST API. Depends on AW only. (See §7.)
|
||||||
|
|
||||||
|
- **③ Context assembler** — builds the brief handed to the brain = relevant
|
||||||
|
collector signals + memory (recent + trend) + the `~/owc` frame (the value/goal
|
||||||
|
at stake, the relevant `bug-*.md`). Budgets it. Depends on ① ② and `~/owc`.
|
||||||
|
Privacy boundary lives here: titles that can leak secrets are filtered before
|
||||||
|
they reach the brain.
|
||||||
|
|
||||||
|
- **Brain adapter** — pluggable. Local: shell `claude --print` / `codex exec`
|
||||||
|
(exactly what AntiDrift's `ai.Backend` already does). Remote: call Hermes over
|
||||||
|
the network. Interface: `decide(brief) -> {status, proposals[]}`. Keel treats it
|
||||||
|
as a black box; swapping it changes nothing else.
|
||||||
|
|
||||||
|
- **④ Effectors** — one gated adapter per write target. Today's safe writes:
|
||||||
|
Marvin task create/complete (`am` / `ammcp`, **preserve the `fieldUpdates`
|
||||||
|
CRDT** on write), capture append (one file), AntiDrift start-session / enforce.
|
||||||
|
Roadmap writes: Beeminder datapoint (`beeline`), `~/owc` note edits. Each
|
||||||
|
effector reads its gate from config (propose-and-confirm | auto).
|
||||||
|
|
||||||
|
- **⑤ Surfaces** — render Keel's state. Web UI (rich, phone-friendly, the
|
||||||
|
interaction/approval surface) and the WM status bar (one live line). Both read
|
||||||
|
the same Keel state; neither owns data.
|
||||||
|
|
||||||
|
- **Control loop / scheduler** — drives the cycle: when to collect, when to think,
|
||||||
|
when to surface, when to act. Cheap triggers (a switch-count spike, a Beeminder
|
||||||
|
losedate approaching, a manual web-UI request) decide when to spend a brain
|
||||||
|
call, instead of polling the LLM blindly. This is AntiDrift's state machine,
|
||||||
|
generalized past the single session.
|
||||||
|
|
||||||
|
- **Policy / gates** — per-effector authority + the value frame. Felix's dial
|
||||||
|
from "read-only" to "act freely". Configured in the web UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AntiDrift is the seed, not a peer
|
||||||
|
|
||||||
|
Keel is AntiDrift's port set, widened. The parts already exist:
|
||||||
|
|
||||||
|
| Keel part | AntiDrift today | Keel (generalized) |
|
||||||
|
|---|---|---|
|
||||||
|
| Collectors | `evidence.Source` (X11), `tasks.Provider` (Marvin) | + AW, HQ, Consume, daily, Beeminder, `~/owc` |
|
||||||
|
| Frame | `knowledge.Source` → one file | `~/owc`: values, goals, 22 `bug-*.md`, frameworks |
|
||||||
|
| Brain | `ai.Backend` → local `claude`/`codex` | + Hermes (remote); pluggable interface |
|
||||||
|
| Memory | ephemeral (dies in `state.json`) | **AW buckets** (durable, queryable) |
|
||||||
|
| Effectors | `enforce.Guard` (window-minimize) | + Marvin, capture, Beeminder, `~/owc` notes |
|
||||||
|
| Surfaces | web UI `:7777` + `~/.keel_status` | richer web UI (phone) + status bar |
|
||||||
|
| Control loop | session state machine (locked/planning/active/review) | **modes** (focus is one); a session is one mode |
|
||||||
|
|
||||||
|
**Loosening the controller** concretely means: generalize `Commitment` from a
|
||||||
|
work-session into a *mode invocation* (a mode has its own collectors, frame slice,
|
||||||
|
brain prompt, and allowed effectors); the focus-session remains the
|
||||||
|
`next_action/success_condition/timebox` mode; the off-screen mode reads the
|
||||||
|
life-domain bugs + goals and proposes one off-screen action; a future "capture"
|
||||||
|
mode would just route a thought. The runtime state machine becomes the loop in §3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. One loop, concrete
|
||||||
|
|
||||||
|
Off-screen mode, from the phone, end to end — **this is what ships today**:
|
||||||
|
1. **Collect** — today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md`
|
||||||
|
goals + the life-domain `~/owc/resources/bug-*.md` notes — plus recent
|
||||||
|
proposals and their outcomes recalled from the `keel.events` AW bucket —
|
||||||
|
assembled into a single budgeted brief.
|
||||||
|
2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen
|
||||||
|
action plus a short rationale.
|
||||||
|
3. **Surface** — the phone-first web UI renders a proposal card and asks Felix to
|
||||||
|
confirm or dismiss.
|
||||||
|
4. **Act** — on confirm, the Marvin `Create` effector files the action as a task
|
||||||
|
(CRDT-preserving `am add`); the mode is one-shot and the harness returns idle.
|
||||||
|
|
||||||
|
No new store touched. The brain was rented for one call. Everything else was Keel.
|
||||||
|
|
||||||
|
**Shipped:** memory. Off-screen now writes its decisions to the `keel.events` AW
|
||||||
|
bucket (`proposal_made` / `action_taken` / `proposal_dismissed`, correlated by a
|
||||||
|
random `proposal_id`) and reads its recent history back into the brief — so a
|
||||||
|
proposal can avoid repeating itself and follow up on what was dismissed or left
|
||||||
|
undone. Storage sits behind a thin `memory.Store` port (AW-backed, or a nop when
|
||||||
|
AW is down), the seam every later mode reuses; see §7. The `keel.state` bucket and
|
||||||
|
the trend/spiral detection that reads this data remain future increments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sources & targets (the real ecosystem)
|
||||||
|
|
||||||
|
| Tool | Read | Write | Role | Source of truth |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ActivityWatch | `POST :5600/api/0/query/`, sqlite | new buckets via REST | sensor **+ store** | `peewee-sqlite.v2.db` |
|
||||||
|
| AntiDrift | `audit.jsonl`, SSE `:7777` | `POST :7777` cmds, enforce | focus mode + enforcement | `~/.keel/` |
|
||||||
|
| HQ | `hq.db` r/o, `:8765` | `dashboard.write_artifact` | agent-execution evidence | `hq.db` (~23k events) |
|
||||||
|
| Consume | `GET :8000/api` | (later) | intake/reflection signal | item frontmatter |
|
||||||
|
| daily | `daily.db` r/o, `:2200` | — | mood/habit checkout | `daily.db` |
|
||||||
|
| Beeminder | `beeline` | `beeline` datapoints | committed-evidence rollups | Beeminder API |
|
||||||
|
| Marvin | `ammcp` / `am --json` | `am` / `ammcp` (keep CRDT) | intent / to-dos / bugs | CouchDB |
|
||||||
|
| `~/owc` | markdown globs, `.zk` | note edits (later) | values/goals/bugs frame | `~/owc` files |
|
||||||
|
| Brain | — | — | rented compute | claude/codex/Hermes |
|
||||||
|
|
||||||
|
Known frictions Keel inherits (from the survey): beesync has **no scheduler**
|
||||||
|
(Beeminder data lags; `[daily]` bridge commented out); the life-bug concept is
|
||||||
|
**forked** (22 rich `~/owc` markdown bugs vs. 5 thin Marvin tasks); **capture is
|
||||||
|
broken** (`blurt` → clipboard, dead `inbox.md`, two rival `stream.md`); AW's year
|
||||||
|
of data is read by nothing today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Memory in ActivityWatch (the storage design)
|
||||||
|
|
||||||
|
AW is a typed, append-only, timestamped event store with buckets + a REST/query
|
||||||
|
API — i.e. it is already the event store the §0 correction said not to rebuild.
|
||||||
|
Keel gets dedicated buckets (e.g. `keel.state`, `keel.events`) and writes
|
||||||
|
**derived** events: `mode_started`, `proposal_made`, `feedback_given`,
|
||||||
|
`action_taken`, `spiral_flagged`, `coach_line`. Properties:
|
||||||
|
- **Reference, don't duplicate** — these hold pointers (a Marvin id, a `bug-*.md`
|
||||||
|
path, an audit `session_id`), not copies of other tools' truth.
|
||||||
|
- **Rebuildable** — derivable from the underlying ledgers; AW is a cache/index of
|
||||||
|
Keel's own decisions plus a join surface over the sources.
|
||||||
|
- **Queryable over time** — this is what unlocks trend/spiral detection ("caffeine
|
||||||
|
bug + mood dip + rising switches, same shape as three weeks ago") that no single
|
||||||
|
tool can do, because each tool only answers "what now".
|
||||||
|
|
||||||
|
Open sub-decision: whether work-laptop AW data is peer-synced in (cross-device
|
||||||
|
behavioral story) or left out initially.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open questions (not yet decided)
|
||||||
|
|
||||||
|
- **Effector gate policy** — exact default authority per write target on day one,
|
||||||
|
and how Felix raises/lowers it from the web UI.
|
||||||
|
- **Capture unification** — which single file is canonical (resolve
|
||||||
|
`~/owc/stream.md` vs `~/wrk/pkb/stream.md` vs dead inboxes) and rewiring `blurt`
|
||||||
|
to append there.
|
||||||
|
- **Cross-device AW** — sync work-laptop AW or not (see §7).
|
||||||
|
- **Secret hygiene** — an increasingly autonomous operator runs as the full Unix
|
||||||
|
user; plaintext keys exist on disk; the privacy boundary in ③ must be real.
|
||||||
|
|
||||||
|
### Resolved
|
||||||
|
|
||||||
|
- **Code rename** — complete as of Phase 0 of the controller refactor. Go module
|
||||||
|
is `keel`, binary is `keeld`, runtime is `~/.keel/`, env prefix is `KEEL_*`.
|
||||||
|
Existing installs with a live ledger under `~/.antidrift/` need a one-time
|
||||||
|
manual move to `~/.keel/`.
|
||||||
|
- **Controller refactor shape** — resolved: grown in place, not branched. The
|
||||||
|
focus-only `session.Controller` is now a generic `harness.Harness` that hosts
|
||||||
|
one `mode.Mode` at a time (the `collect → brain → act` loop of §3); focus is the
|
||||||
|
first mode and off-screen the second. Per-mode persistence lives under
|
||||||
|
`~/.keel/modes/<kind>/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What this supersedes (from `~/dev/higher/`)
|
||||||
|
|
||||||
|
- The whole `~/dev/higher/` planning directory: it was the scaffold for deciding
|
||||||
|
to extend AntiDrift. This doc is now the authoritative home; that dir retires to
|
||||||
|
a pointer.
|
||||||
|
- "clawd" → **`~/owc`** everywhere.
|
||||||
|
- The plan's premise that the operator "already knows Felix via `~/.hermes`" and
|
||||||
|
the survey finding "Hermes doesn't exist" are both resolved: **Hermes is real
|
||||||
|
but remote, and the brain is pluggable** — grounding comes from the `~/owc`
|
||||||
|
frame regardless of which brain runs.
|
||||||
|
- The plan's §6 recommendation ("a thin *reader* layer is the real architecture")
|
||||||
|
is **superseded**: the reader is merely the **collectors layer** of Keel. The
|
||||||
|
target is the full system (one screen, two surfaces, durable memory,
|
||||||
|
gated→autonomous effectors), grown from AntiDrift.
|
||||||
|
- "Don't build an event store" is **sharpened**: storage = **ActivityWatch
|
||||||
|
buckets**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Smallest real slice (a vertical, not a summary)
|
||||||
|
|
||||||
|
To stay honest to "ship visible proof" without boiling the ocean, the first slice
|
||||||
|
is **off-screen mode, end to end** (§5) on the existing AntiDrift web surface —
|
||||||
|
collectors (`~/owc` goals + life-domain bugs + Marvin), the brain (`ai.Proposer`),
|
||||||
|
the phone-first web UI proposal card, and the Marvin `Create` effector behind a
|
||||||
|
confirm gate. It exercises every Keel component once except memory, on the
|
||||||
|
smallest mode, and produces a real filed task — interaction and action, not a
|
||||||
|
coaching sentence. **This slice now ships;** durable AW memory (§7) is the next
|
||||||
|
increment that closes the loop.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,674 +0,0 @@
|
|||||||
# Commitment OS Design
|
|
||||||
|
|
||||||
Date: 2026-05-25
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
AntiDrift should evolve from a session timer and window-rating tool into a commitment operating system: a personal agency layer that makes computer use explicit, constrained, observable, and resistant to casual tampering.
|
|
||||||
|
|
||||||
The system addresses distraction as unconscious context switching, not only as access to bad websites. The guiding rule is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
No unchosen transitions.
|
|
||||||
```
|
|
||||||
|
|
||||||
The computer should not silently enter unconstrained use. It should be in one of these explicit modes:
|
|
||||||
|
|
||||||
- active commitment;
|
|
||||||
- deliberate transition;
|
|
||||||
- constrained planning;
|
|
||||||
- review;
|
|
||||||
- locked state;
|
|
||||||
- delayed administrative override.
|
|
||||||
|
|
||||||
Tamper resistance is a friction gradient against the user's impulsive self. It is not a perfect security boundary. The goal is to defeat impulsive and casual bypasses, delay deliberate bypasses, and make drift visible.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The system has five layers that share a common commitment model.
|
|
||||||
|
|
||||||
1. Orientation: projects, responsibilities, values, tasks, next actions, energy constraints.
|
|
||||||
2. Commitment: a concrete next action, success condition, allowed context, timebox, and transition policy.
|
|
||||||
3. Live work mode: desktop, browser, terminal, files, ActivityWatch, window tracking, and future evidence sources.
|
|
||||||
4. Containment: a privileged guardian that enforces the selected commitment across the live environment.
|
|
||||||
5. Reflection: session outcomes, drift events, transition reasons, rule adjustments, and planner updates.
|
|
||||||
|
|
||||||
The commitment model sits between planning and enforcement. Planner integration can later create commitments from tasks. Enforcement operates on policy snapshots derived from commitments without understanding planner internals.
|
|
||||||
|
|
||||||
## Threat Model And Scope
|
|
||||||
|
|
||||||
The adversary is the same person in a less reflective state. The system should be honest about what local software can and cannot enforce.
|
|
||||||
|
|
||||||
| Tier | Bypass effort | Examples | Realistic goal |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 0: impulse | less than 1 second | Alt-tab, open browser, type URL, leave desk without choosing transition | Defeat with blocking, prompts, overlays, and explicit transitions |
|
|
||||||
| 1: casual | seconds | Kill process, stop user service, close app, quit ActivityWatch | Defeat with watchdogs, privilege separation, and restart behavior |
|
|
||||||
| 2: determined | minutes | Use sudo, edit config, disable systemd units, reboot, manipulate clock | Delay and audit through delayed admin path and break-glass flow |
|
|
||||||
| 3: premeditated | unconstrained | Boot live USB, GRUB recovery, second device, phone, hardware removal | Out of scope for local software |
|
|
||||||
|
|
||||||
In-scope bypasses:
|
|
||||||
|
|
||||||
- opening disallowed domains or apps;
|
|
||||||
- quitting AntiDrift or ActivityWatch;
|
|
||||||
- stopping user-level services;
|
|
||||||
- entering unconstrained planning;
|
|
||||||
- changing rules without delayed admin access;
|
|
||||||
- crash-looping the user agent;
|
|
||||||
- malformed policy snapshots;
|
|
||||||
- clock manipulation attempts visible to the running system;
|
|
||||||
- unmonitored desk absence after future presence sensing exists.
|
|
||||||
|
|
||||||
Out-of-scope or explicitly limited:
|
|
||||||
|
|
||||||
- second phones, tablets, or other computers;
|
|
||||||
- booting from external media or recovery shell;
|
|
||||||
- physical tampering;
|
|
||||||
- fully premeditated bypasses with unlimited time;
|
|
||||||
- coercive monitoring of another person.
|
|
||||||
|
|
||||||
Known edge cases to address or scope per implementation stage:
|
|
||||||
|
|
||||||
- switching TTYs or desktop sessions;
|
|
||||||
- Wayland compositor limitations;
|
|
||||||
- VMs and nested display servers;
|
|
||||||
- SSH into the same machine;
|
|
||||||
- browser profiles and extensions;
|
|
||||||
- Flatpak/Snap application identity;
|
|
||||||
- multi-user machines;
|
|
||||||
- multiple monitors, virtual desktops, and tiling window managers;
|
|
||||||
- direct modification of ActivityWatch data.
|
|
||||||
|
|
||||||
Stage 1 does not need to solve every edge case, but it must not pretend they are solved.
|
|
||||||
|
|
||||||
## Linux Target Environment
|
|
||||||
|
|
||||||
The first target is the user's Linux workstation.
|
|
||||||
|
|
||||||
Stage 1 should support the current X11-style active-window model already used by AntiDrift through `xdotool`, plus ActivityWatch evidence when available. If the session is Wayland and active window metadata cannot be collected reliably, the system must surface that as a degraded evidence mode rather than silently reporting false confidence.
|
|
||||||
|
|
||||||
Stage 2 should choose concrete Linux enforcement mechanisms per target environment:
|
|
||||||
|
|
||||||
- systemd system service for the privileged guardian;
|
|
||||||
- systemd user service or normal user process for the agent;
|
|
||||||
- nftables or DNS-level controls for domain blocking;
|
|
||||||
- window-class interruption where active window details are available;
|
|
||||||
- ActivityWatch watchdog where ActivityWatch is installed and expected.
|
|
||||||
|
|
||||||
Wayland support should be compositor-specific. GNOME Wayland, KDE Wayland, Sway, and Hyprland may need separate adapters. Unknown or unsupported environments should fail into a conservative mode: planning/review may work, but enforcement claims are limited.
|
|
||||||
|
|
||||||
## Domain Model
|
|
||||||
|
|
||||||
The first-pass `Commitment` concept should be split into three related records.
|
|
||||||
|
|
||||||
### Commitment
|
|
||||||
|
|
||||||
User-facing intent and planning artifact.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Commitment
|
|
||||||
id
|
|
||||||
created_at
|
|
||||||
source: manual | planner | recurring | recovery | template
|
|
||||||
project_id: optional initially, planner-backed later
|
|
||||||
template_id: optional
|
|
||||||
next_action: concrete executable action
|
|
||||||
success_condition: what counts as done or meaningfully advanced
|
|
||||||
timebox
|
|
||||||
transition_policy_id
|
|
||||||
state: draft | active | paused | completed | abandoned | violated
|
|
||||||
```
|
|
||||||
|
|
||||||
Only one commitment may be active at a time. Task switches go through `Transition` and either resume the current commitment, abandon it, or create/select a new one.
|
|
||||||
|
|
||||||
### PolicySnapshot
|
|
||||||
|
|
||||||
Versioned enforcement contract consumed by the guardian.
|
|
||||||
|
|
||||||
```text
|
|
||||||
PolicySnapshot
|
|
||||||
id
|
|
||||||
commitment_id
|
|
||||||
schema_version
|
|
||||||
created_at
|
|
||||||
runtime_state: locked | planning | active | transition | review | admin_override
|
|
||||||
enforcement_level: observe | warn | block | locked
|
|
||||||
allowed_context
|
|
||||||
required_monitors
|
|
||||||
violation_actions
|
|
||||||
expires_at
|
|
||||||
generated_by_agent_version
|
|
||||||
```
|
|
||||||
|
|
||||||
The guardian consumes `PolicySnapshot`, not planner internals.
|
|
||||||
|
|
||||||
### SessionRecord And EventLog
|
|
||||||
|
|
||||||
Append-only history of what happened.
|
|
||||||
|
|
||||||
```text
|
|
||||||
SessionRecord
|
|
||||||
id
|
|
||||||
commitment_id
|
|
||||||
started_at
|
|
||||||
ended_at
|
|
||||||
outcome: completed | partial | abandoned | violated
|
|
||||||
review_rating
|
|
||||||
review_notes
|
|
||||||
|
|
||||||
EventLog
|
|
||||||
sequence
|
|
||||||
timestamp
|
|
||||||
event_type
|
|
||||||
commitment_id
|
|
||||||
runtime_state
|
|
||||||
payload
|
|
||||||
previous_hash
|
|
||||||
hash
|
|
||||||
```
|
|
||||||
|
|
||||||
The event log is the source for review, audit, and later planner writeback.
|
|
||||||
|
|
||||||
## Allowed Context Semantics
|
|
||||||
|
|
||||||
Allowed context should be explicit and conservative. Stage 1 may only observe or warn, but the matching rules should be defined early so Stage 2 can enforce the same contract.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
allowed_context:
|
|
||||||
window_classes:
|
|
||||||
match: exact_lowercase
|
|
||||||
values:
|
|
||||||
- code
|
|
||||||
- alacritty
|
|
||||||
window_titles:
|
|
||||||
match: substring
|
|
||||||
values:
|
|
||||||
- antidrift
|
|
||||||
domains:
|
|
||||||
match: exact_or_subdomain
|
|
||||||
values:
|
|
||||||
- github.com
|
|
||||||
- docs.python.org
|
|
||||||
repos:
|
|
||||||
match: canonical_path_prefix
|
|
||||||
values:
|
|
||||||
- ~/dev/antidrift
|
|
||||||
commands:
|
|
||||||
match: executable_basename
|
|
||||||
values:
|
|
||||||
- cargo
|
|
||||||
- git
|
|
||||||
- rg
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Domains include subdomains unless explicitly marked exact-only.
|
|
||||||
- Full URLs should not be stored by default; store domain and coarse path only when needed.
|
|
||||||
- Window classes are preferred over titles because titles can contain private data and change frequently.
|
|
||||||
- Unknown windows are handled by `enforcement_level`: record in `observe`, prompt in `warn`, interrupt in `block`, and force `Locked` on repeated or severe violations in `locked`.
|
|
||||||
- Terminals are hard to classify. Stage 1 may treat terminal windows as allowed based on window class and record command evidence later only when a shell integration exists.
|
|
||||||
- Child process handling is deferred until command/process enforcement is designed. Stage 2 should not claim process-level policy without cgroups, namespaces, AppArmor, seccomp, or equivalent mechanisms.
|
|
||||||
- Commitments may use per-resource actions. Example: block social domains, warn on unfamiliar apps, observe unknown terminal commands.
|
|
||||||
|
|
||||||
Commitment templates and project defaults should reduce manual allowlist construction:
|
|
||||||
|
|
||||||
- `deep coding`;
|
|
||||||
- `writing`;
|
|
||||||
- `admin`;
|
|
||||||
- `research`;
|
|
||||||
- `rest`;
|
|
||||||
- project-specific default repos, apps, and documentation domains.
|
|
||||||
|
|
||||||
Time-limited context expansion should be allowed when work legitimately discovers a new need. Expansion must be explicit, audited, and bounded, such as "allow `docs.rs` for 20 minutes for this commitment."
|
|
||||||
|
|
||||||
## Runtime State Machine
|
|
||||||
|
|
||||||
Runtime state controls what the machine is allowed to do now.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Locked
|
|
||||||
No valid active commitment. Distracting/default computer use is blocked.
|
|
||||||
|
|
||||||
Planning
|
|
||||||
Constrained UI for inspecting tasks/projects and creating or choosing a commitment.
|
|
||||||
|
|
||||||
Active
|
|
||||||
A commitment is running. Allowed context is available; disallowed context is blocked or interrupted.
|
|
||||||
|
|
||||||
Transition
|
|
||||||
Intentional state change: bodily break, reset, task switch, or session end.
|
|
||||||
Requires reason, expected duration, and return target.
|
|
||||||
|
|
||||||
Review
|
|
||||||
Session ended. The user rates relevance, marks outcome, and records drift/avoidance patterns.
|
|
||||||
|
|
||||||
Admin Override
|
|
||||||
Privileged change path. Requires delayed admin access and leaves audit evidence.
|
|
||||||
```
|
|
||||||
|
|
||||||
Legal transitions:
|
|
||||||
|
|
||||||
| From | To | Guard | Side effects |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Locked | Planning | Planning UI requested | Apply planning policy, start planning timer |
|
|
||||||
| Planning | Active | Valid commitment and policy snapshot accepted by guardian | Create session record, apply active policy |
|
|
||||||
| Planning | Locked | Cancel, timeout, invalid policy, or idle | Apply locked policy |
|
|
||||||
| Active | Transition | Reason, expected duration, and return target provided | Record transition start, apply transition policy |
|
|
||||||
| Active | Review | Commitment completed, abandoned, or timebox expired | Stop active policy, collect evidence summary |
|
|
||||||
| Active | Locked | Severe violation, policy failure, or monitor failure | Record violation, apply locked policy |
|
|
||||||
| Transition | Active | Return before timeout to same commitment | Record return, reapply active policy |
|
|
||||||
| Transition | Planning | Task switch requested | Mark current commitment paused/abandoned as chosen, apply planning policy |
|
|
||||||
| Transition | Review | End session requested | Collect review data |
|
|
||||||
| Transition | Locked | Timeout exceeded or transition policy failure | Record violation, apply locked policy |
|
|
||||||
| Review | Planning | Continue working | Store review, apply planning policy |
|
|
||||||
| Review | Locked | End work period | Store review, apply locked policy |
|
|
||||||
| Any | Admin Override | Delayed admin path completed | Record override, apply requested privileged change |
|
|
||||||
| Admin Override | Previous/new state | Override ends | Record result, apply selected policy |
|
|
||||||
|
|
||||||
Planning must never become unconstrained browsing. It has its own policy:
|
|
||||||
|
|
||||||
- only the planning UI, local task data, and approved references are available;
|
|
||||||
- planning is time-limited;
|
|
||||||
- evidence collection remains active;
|
|
||||||
- opening arbitrary browser tabs from Planning is a violation unless explicitly allowed.
|
|
||||||
|
|
||||||
Suspend/hibernate and reboot should resume into `Locked` unless a valid policy can be restored and verified. A violated commitment may be resumed only through `Review` or `Planning`; resumption should create a visible event.
|
|
||||||
|
|
||||||
## Commitment Lifecycle
|
|
||||||
|
|
||||||
Commitment state is distinct from runtime state.
|
|
||||||
|
|
||||||
| From | To | Guard | Notes |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| draft | active | Runtime enters `Active` with accepted policy | Only one active commitment at a time |
|
|
||||||
| active | paused | Runtime enters `Transition` with return target | Paused does not imply unconstrained use |
|
|
||||||
| paused | active | User returns before transition timeout | Same commitment resumes |
|
|
||||||
| active | completed | Success condition met or user completes in review | Requires review |
|
|
||||||
| active | abandoned | User chooses task switch/end without completion | Requires reason |
|
|
||||||
| active | violated | Severe or unresolved violation | May force runtime `Locked` |
|
|
||||||
| paused | abandoned | Transition becomes task switch/end | Requires reason |
|
|
||||||
| violated | active | Explicit recovery flow | Audited; not automatic |
|
|
||||||
| violated | abandoned | Review confirms abandonment | Stored in history |
|
|
||||||
|
|
||||||
## Guardian IPC Contract
|
|
||||||
|
|
||||||
The user agent and guardian communicate through a narrow local API.
|
|
||||||
|
|
||||||
Recommended transport: root-owned Unix domain socket with a small JSON-lines protocol. D-Bus can be reconsidered later if desktop integration requires it, but the first design should avoid unnecessary framework surface.
|
|
||||||
|
|
||||||
Authentication and integrity:
|
|
||||||
|
|
||||||
- The socket path is owned by root and writable only by the expected user/group.
|
|
||||||
- The guardian checks peer credentials with `SO_PEERCRED`.
|
|
||||||
- Messages include `schema_version`, monotonic sequence, and request id.
|
|
||||||
- The guardian rejects malformed, unknown-version, stale, or unauthenticated messages.
|
|
||||||
- Policy changes are logged before and after application.
|
|
||||||
|
|
||||||
Minimal API:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ApplyPolicy(PolicySnapshot) -> Result
|
|
||||||
ReportEvent(Event) -> Ack
|
|
||||||
GetStatus() -> SystemState
|
|
||||||
RequestOverride(OverrideRequest) -> OverrideResult
|
|
||||||
```
|
|
||||||
|
|
||||||
Failure behavior:
|
|
||||||
|
|
||||||
- If the agent disconnects while `Active`, the guardian keeps the last valid policy for a short grace period, then moves to `Locked`.
|
|
||||||
- If the agent sends malformed policy, the guardian rejects it and keeps the previous valid policy; repeated malformed policy escalates to `Locked`.
|
|
||||||
- If IPC is unavailable, the agent should show degraded state and the system should not claim enforcement.
|
|
||||||
- If guardian and agent schema versions are incompatible, the system should enter `Locked` with an actionable error and break-glass instructions.
|
|
||||||
|
|
||||||
## Enforcement Mechanism Matrix
|
|
||||||
|
|
||||||
Stage 2 enforcement should be explicit about feasibility.
|
|
||||||
|
|
||||||
| Resource | Stage 1 | Stage 2 candidate | Notes |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Domain | observe from ActivityWatch/browser data if available | nftables or DNS-level blocking | Prefer domains over full URLs for privacy |
|
|
||||||
| App/window | active-window tracking, minimize or overlay | window-class interruption, minimize, SIGSTOP only where safe | Depends on X11/Wayland adapter |
|
|
||||||
| Agent health | local process check | systemd restart and guardian watchdog | Casual process killing should fail |
|
|
||||||
| ActivityWatch health | observe availability | guardian restart and escalation after repeated failure | Direct DB tampering only audited if detectable |
|
|
||||||
| Config changes | local file checks | root-owned config, delayed admin required | Config writes must be audited |
|
|
||||||
| Commands/processes | mostly out of scope | future shell integration, cgroups, AppArmor, seccomp, namespaces | Do not overclaim early |
|
|
||||||
| Files/repos | local path metadata | future filesystem policy only if needed | Path allowlists are mostly planning/evidence initially |
|
|
||||||
| Presence | out of scope | future opt-in sensor events | Requires separate ethics/design review |
|
|
||||||
|
|
||||||
Stage 1 should include Tier 0 friction even without privileged blocking:
|
|
||||||
|
|
||||||
- full-screen or high-priority overlay on violation where feasible;
|
|
||||||
- dismissal requires entering a reason;
|
|
||||||
- explicit transition prompts;
|
|
||||||
- local event logging tied to the active commitment.
|
|
||||||
|
|
||||||
## Delayed Admin And Break-Glass Recovery
|
|
||||||
|
|
||||||
The delayed admin mechanism is load-bearing and must be concrete before Stage 2 hardening.
|
|
||||||
|
|
||||||
Initial assumption:
|
|
||||||
|
|
||||||
- daily work happens in a non-admin user account;
|
|
||||||
- admin access exists in a separate admin account or equivalent path;
|
|
||||||
- admin access already has a delay, currently about 10 minutes, and can be lengthened;
|
|
||||||
- privileged enforcement changes require that delayed path.
|
|
||||||
|
|
||||||
Actions requiring delayed admin:
|
|
||||||
|
|
||||||
- disabling guardian service;
|
|
||||||
- changing locked policy files;
|
|
||||||
- changing blocklists or allowlists outside explicit time-limited context expansion;
|
|
||||||
- uninstalling enforcement;
|
|
||||||
- disabling watchdog behavior;
|
|
||||||
- emergency break-glass.
|
|
||||||
|
|
||||||
Override requests should be logged at request time and completion time:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OverrideRequest
|
|
||||||
id
|
|
||||||
requested_at
|
|
||||||
requester_user
|
|
||||||
reason
|
|
||||||
requested_action
|
|
||||||
earliest_allowed_at
|
|
||||||
completed_at
|
|
||||||
result
|
|
||||||
```
|
|
||||||
|
|
||||||
Pre-scheduled instant overrides weaken the system. The guardian should reject override tokens or sentinel files created before the current request window unless they are part of the audited delayed-admin protocol.
|
|
||||||
|
|
||||||
Break-glass recovery must be independent of the normal agent UI. Example: a privileged command or root-owned sentinel file disables enforcement for 10 minutes after delayed admin access. It must:
|
|
||||||
|
|
||||||
- be simple enough to trust under failure;
|
|
||||||
- be audited;
|
|
||||||
- have automatic expiry;
|
|
||||||
- restore normal policy after expiry unless explicitly uninstalled;
|
|
||||||
- not depend on the user agent being healthy.
|
|
||||||
|
|
||||||
## Event Log, Tamper Evidence, And Retention
|
|
||||||
|
|
||||||
Events should be append-only and bounded.
|
|
||||||
|
|
||||||
Recommended initial format: JSON lines with hash chaining.
|
|
||||||
|
|
||||||
```text
|
|
||||||
event_hash = hash(schema_version, sequence, timestamp, event_type, payload, previous_hash)
|
|
||||||
```
|
|
||||||
|
|
||||||
The log should include:
|
|
||||||
|
|
||||||
- policy applications;
|
|
||||||
- state transitions;
|
|
||||||
- violations;
|
|
||||||
- process/monitor failures;
|
|
||||||
- override requests and completions;
|
|
||||||
- review outcomes.
|
|
||||||
|
|
||||||
Retention and storage:
|
|
||||||
|
|
||||||
- rotate logs by size and time;
|
|
||||||
- enforce max disk usage;
|
|
||||||
- define behavior when disk is full;
|
|
||||||
- support export and deletion through delayed admin if enforcement logs are involved;
|
|
||||||
- include schema version and migration path.
|
|
||||||
|
|
||||||
If logging fails:
|
|
||||||
|
|
||||||
- non-critical review notes may be skipped with visible warning;
|
|
||||||
- enforcement/audit events should fail conservative, usually `Locked`, unless doing so would create an unsafe machine lockout;
|
|
||||||
- break-glass must still work even when normal logging is impaired, with best-effort emergency log.
|
|
||||||
|
|
||||||
## Privacy, Ethics, And Data Retention
|
|
||||||
|
|
||||||
This is voluntary self-use software. It must not become bossware, parental control software, or coercive monitoring.
|
|
||||||
|
|
||||||
Principles:
|
|
||||||
|
|
||||||
- local-first storage by default;
|
|
||||||
- no network transmission unless explicitly configured;
|
|
||||||
- minimum necessary observation;
|
|
||||||
- prefer domain over full URL;
|
|
||||||
- prefer window class over title where possible;
|
|
||||||
- treat window titles as sensitive because they can contain private messages, documents, or secrets;
|
|
||||||
- encrypt logs/history at rest if feasible, especially once planner and presence data exist;
|
|
||||||
- provide retention, deletion, and export tools;
|
|
||||||
- make data collection visible to the user;
|
|
||||||
- include first-class `rest` and `exploration` commitments so legitimate openness and recovery are not treated as failure.
|
|
||||||
|
|
||||||
The system should increase agency, not create surveillance anxiety. Presence sensing, camera use, posture detection, and phone pickup detection require a separate opt-in design and ethics review after the core system is useful.
|
|
||||||
|
|
||||||
## Failure Handling
|
|
||||||
|
|
||||||
Failures should have explicit fail-open or fail-closed behavior.
|
|
||||||
|
|
||||||
| Failure | Default behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| User agent crash | Guardian restarts agent; if repeated, enter `Locked` |
|
|
||||||
| User agent crash loop | Enter `Locked`, show recovery instructions |
|
|
||||||
| Guardian crash | systemd restarts guardian; until restored, user agent shows enforcement degraded |
|
|
||||||
| Guardian crash loop | Fail conservative where possible, but keep break-glass available |
|
|
||||||
| ActivityWatch stopped | Guardian restarts it; repeated failure escalates to `Locked` if ActivityWatch is required |
|
|
||||||
| ActivityWatch unavailable | Run degraded if policy does not require it; otherwise refuse `Active` |
|
|
||||||
| ActivityWatch data corrupted | Mark evidence degraded, preserve raw error, continue only if policy allows |
|
|
||||||
| Policy apply failure | Refuse `Active`; stay `Planning` or move `Locked` |
|
|
||||||
| Malformed policy | Reject, keep previous valid policy, escalate after repeated failures |
|
|
||||||
| Disk full/log write failure | Warn; fail conservative for enforcement events; keep break-glass available |
|
|
||||||
| System reboot | Start in `Locked` until state is restored and verified |
|
|
||||||
| Suspend/hibernate | On resume, revalidate timebox and policy; otherwise enter `Locked` |
|
|
||||||
| Clock manipulation | Prefer monotonic timers for timeboxes; record wall-clock anomalies |
|
|
||||||
| OS updates | Require delayed admin or maintenance commitment |
|
|
||||||
| Emergency interruption | Use transition or break-glass depending on severity |
|
|
||||||
| Schema migration failure | Enter `Locked` with recovery instructions |
|
|
||||||
|
|
||||||
The primary invariant is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Without an active valid commitment, the machine cannot silently enter unconstrained use.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Locked, Planning, Transition, And Review UX
|
|
||||||
|
|
||||||
Locked state should show a small, reliable UI:
|
|
||||||
|
|
||||||
- current status;
|
|
||||||
- reason for lock;
|
|
||||||
- option to enter constrained Planning;
|
|
||||||
- option to review last session if pending;
|
|
||||||
- break-glass instructions that require delayed admin.
|
|
||||||
|
|
||||||
Planning should be useful but constrained:
|
|
||||||
|
|
||||||
- local project/task list;
|
|
||||||
- commitment templates;
|
|
||||||
- recent commitments;
|
|
||||||
- allowed local notes or references;
|
|
||||||
- time limit;
|
|
||||||
- no arbitrary browsing unless a planning policy explicitly allows it.
|
|
||||||
|
|
||||||
Transition should force consciousness into state changes:
|
|
||||||
|
|
||||||
- reason;
|
|
||||||
- expected duration;
|
|
||||||
- return target;
|
|
||||||
- optional note;
|
|
||||||
- timeout behavior.
|
|
||||||
|
|
||||||
Review should be difficult to skip:
|
|
||||||
|
|
||||||
- mark outcome;
|
|
||||||
- rate relevance;
|
|
||||||
- record drift or transition failures;
|
|
||||||
- update commitment/task state;
|
|
||||||
- generate next suggested commitment where useful.
|
|
||||||
|
|
||||||
## Staged Implementation
|
|
||||||
|
|
||||||
### Stage 1a: Commitment Kernel
|
|
||||||
|
|
||||||
- Commitment schema.
|
|
||||||
- PolicySnapshot schema without privileged enforcement.
|
|
||||||
- Runtime and commitment state machines.
|
|
||||||
- Append-only local event log.
|
|
||||||
- Unit tests for legal and illegal transitions.
|
|
||||||
|
|
||||||
### Stage 1b: Session UI And Evidence
|
|
||||||
|
|
||||||
- Session UI using commitment fields.
|
|
||||||
- Review flow.
|
|
||||||
- Activity/window evidence linked to `commitment_id`.
|
|
||||||
- Degraded evidence reporting when ActivityWatch or window metadata is unavailable.
|
|
||||||
|
|
||||||
### Stage 1c: Tier 0 Friction
|
|
||||||
|
|
||||||
- Full-screen or high-priority overlay on violation where feasible.
|
|
||||||
- Dismissal requires deliberate reason entry.
|
|
||||||
- Explicit transition prompts.
|
|
||||||
- Unknown-window/domain behavior based on enforcement level.
|
|
||||||
|
|
||||||
### Stage 2a: Guardian Skeleton
|
|
||||||
|
|
||||||
- systemd system service for guardian.
|
|
||||||
- systemd user service or launcher for agent.
|
|
||||||
- Watchdog restart behavior for user agent and ActivityWatch.
|
|
||||||
- Minimal status reporting.
|
|
||||||
|
|
||||||
### Stage 2b: IPC And Policy Delivery
|
|
||||||
|
|
||||||
- Root-owned Unix domain socket.
|
|
||||||
- JSON-lines protocol.
|
|
||||||
- `ApplyPolicy`, `ReportEvent`, `GetStatus`, `RequestOverride`.
|
|
||||||
- Schema versioning and malformed-message handling.
|
|
||||||
|
|
||||||
### Stage 2c: Domain Blocking
|
|
||||||
|
|
||||||
- nftables or DNS-level policy application.
|
|
||||||
- Domain matching semantics.
|
|
||||||
- Policy rollback on failure.
|
|
||||||
|
|
||||||
### Stage 2d: App/Window Interruption
|
|
||||||
|
|
||||||
- X11 adapter first if current environment is X11.
|
|
||||||
- Compositor-specific Wayland adapters only after verification.
|
|
||||||
- Minimize, overlay, or interrupt based on policy.
|
|
||||||
|
|
||||||
### Stage 2e: Tamper-Evident Logs And Retention
|
|
||||||
|
|
||||||
- Hash-chained events.
|
|
||||||
- Rotation and max disk usage.
|
|
||||||
- Export/delete path.
|
|
||||||
- Log failure handling.
|
|
||||||
|
|
||||||
### Stage 2f: Delayed Admin And Config Lockdown
|
|
||||||
|
|
||||||
- Root-owned policy/config.
|
|
||||||
- Delayed override protocol.
|
|
||||||
- Break-glass command.
|
|
||||||
- Uninstall/rollback tooling.
|
|
||||||
|
|
||||||
### Stage 3a: Project And Task Model
|
|
||||||
|
|
||||||
- Projects.
|
|
||||||
- Tasks.
|
|
||||||
- Next actions.
|
|
||||||
- Recurring responsibilities.
|
|
||||||
|
|
||||||
### Stage 3b: Commitment Templates From Tasks
|
|
||||||
|
|
||||||
- Project defaults.
|
|
||||||
- Work-mode templates.
|
|
||||||
- Task selection during Planning state.
|
|
||||||
- Time-limited context expansion.
|
|
||||||
|
|
||||||
### Stage 3c: Outcome Writeback
|
|
||||||
|
|
||||||
- Session outcomes written back to tasks.
|
|
||||||
- Review suggestions based on drift history.
|
|
||||||
- Next commitment suggestions.
|
|
||||||
|
|
||||||
## Future Direction: Embodied And Presence Sensing
|
|
||||||
|
|
||||||
Presence sensing is not part of the core roadmap. It may be considered later only if:
|
|
||||||
|
|
||||||
- the core system has proven useful;
|
|
||||||
- the user explicitly opts in;
|
|
||||||
- data minimization is defined;
|
|
||||||
- psychological safety is reviewed;
|
|
||||||
- retention and deletion behavior are explicit.
|
|
||||||
|
|
||||||
Potential future events:
|
|
||||||
|
|
||||||
- `desk_absence_started`;
|
|
||||||
- `desk_absence_resolved`;
|
|
||||||
- `posture_warning`;
|
|
||||||
- `phone_pickup_detected`.
|
|
||||||
|
|
||||||
These should be evidence events, not core dependencies.
|
|
||||||
|
|
||||||
## Uninstall And Rollback
|
|
||||||
|
|
||||||
The system must have a clean removal path.
|
|
||||||
|
|
||||||
Uninstall should:
|
|
||||||
|
|
||||||
- disable guardian service;
|
|
||||||
- disable user agent service;
|
|
||||||
- remove nftables or DNS rules;
|
|
||||||
- remove root-owned policy/config files after delayed admin confirmation;
|
|
||||||
- export or delete logs;
|
|
||||||
- restore normal admin behavior if it was modified for AntiDrift;
|
|
||||||
- verify that no blocking policy remains active.
|
|
||||||
|
|
||||||
Rollback should support returning from a failed upgrade to the previous known-good policy and guardian version.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
Testing must include bypass resistance, not only happy paths.
|
|
||||||
|
|
||||||
Unit tests:
|
|
||||||
|
|
||||||
- commitment validation;
|
|
||||||
- legal and illegal runtime transitions;
|
|
||||||
- legal and illegal commitment transitions;
|
|
||||||
- PolicySnapshot generation;
|
|
||||||
- allowed context matching;
|
|
||||||
- event hash chaining.
|
|
||||||
|
|
||||||
Property tests:
|
|
||||||
|
|
||||||
- no unconstrained use without valid active commitment;
|
|
||||||
- illegal transitions do not mutate state;
|
|
||||||
- expired policy snapshots are rejected.
|
|
||||||
|
|
||||||
Integration tests:
|
|
||||||
|
|
||||||
- user agent writes state;
|
|
||||||
- guardian reads policy;
|
|
||||||
- simulated window/ActivityWatch events produce expected violations;
|
|
||||||
- malformed policy is rejected;
|
|
||||||
- agent disconnect triggers grace period then `Locked`;
|
|
||||||
- timebox expiry moves to `Review` or `Locked` as configured.
|
|
||||||
|
|
||||||
Adversarial VM/manual tests:
|
|
||||||
|
|
||||||
- kill agent;
|
|
||||||
- stop ActivityWatch;
|
|
||||||
- attempt blocked domain;
|
|
||||||
- malformed policy;
|
|
||||||
- guardian restart;
|
|
||||||
- guardian crash loop;
|
|
||||||
- timebox expiry;
|
|
||||||
- config change attempt;
|
|
||||||
- reboot during active commitment;
|
|
||||||
- suspend/resume during active commitment;
|
|
||||||
- disk full or log write failure.
|
|
||||||
|
|
||||||
Soak and upgrade tests:
|
|
||||||
|
|
||||||
- 24-48 hour run with normal use;
|
|
||||||
- log rotation;
|
|
||||||
- schema migration;
|
|
||||||
- downgrade/rollback;
|
|
||||||
- IPC fuzz tests.
|
|
||||||
|
|
||||||
The first implementation plan should target Stage 1a through Stage 1c while preserving the process boundary and data contracts needed for Stage 2 and planner integration.
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
# AntiDrift Go Reimagining — Design
|
|
||||||
|
|
||||||
Date: 2026-05-31
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
AntiDrift is being reimagined from its current Rust implementation (~7,500
|
|
||||||
lines) into Go, to become the user's focus operating system on the computer:
|
|
||||||
fast, good-looking, and deeply integrated with AI from the start.
|
|
||||||
|
|
||||||
This is **not a faithful 1:1 port**. The existing domain model and the
|
|
||||||
`commitment-os-design.md` spec remain the north star. The Rust code is a
|
|
||||||
reference, not a thing to replicate line-for-line. The move to Go is an
|
|
||||||
opportunity to shed incidental complexity — most notably the token-heavy
|
|
||||||
event-log replay/revalidation design — while preserving what is genuinely
|
|
||||||
valuable.
|
|
||||||
|
|
||||||
### Why Go, why now
|
|
||||||
|
|
||||||
- **Token efficiency for AI-assisted development.** The current pain is not
|
|
||||||
runtime cost; it is that any AI edit to the core must load
|
|
||||||
`session.rs` (3,475 lines, ~80% replay-validation logic and its tests).
|
|
||||||
Go's smaller idioms plus a redesigned, smaller core directly reduce the
|
|
||||||
context any change requires.
|
|
||||||
- **Predictable LLM codegen.** Go's rigid syntax produces functional code on
|
|
||||||
the first pass with fewer correction loops.
|
|
||||||
- **Runtime fit.** Concurrency and local HTTP serving are first-class, which
|
|
||||||
suits a long-running focus daemon that also talks to AI.
|
|
||||||
|
|
||||||
## What Carries Over vs. What Changes
|
|
||||||
|
|
||||||
**Preserved (high value, ports cleanly):**
|
|
||||||
|
|
||||||
- The domain model: `Commitment`, `PolicySnapshot`, `RuntimeState`,
|
|
||||||
`CommitmentState`, `AllowedContext`, `EnforcementLevel`.
|
|
||||||
- The pure runtime/commitment state machines (`state_machine.rs`) — a near 1:1
|
|
||||||
port.
|
|
||||||
- The `commitment-os-design.md` spec as the conceptual foundation, including
|
|
||||||
"no unchosen transitions" and the staged threat model.
|
|
||||||
- Hash-chained tamper evidence — but relocated to the audit log only.
|
|
||||||
|
|
||||||
**Reimagined:**
|
|
||||||
|
|
||||||
- **Persistence.** Replace replay-everything-and-revalidate-on-startup with an
|
|
||||||
in-memory state-of-truth, a persisted **snapshot**, and an append-only audit
|
|
||||||
log. This removes roughly 3,000 lines (the bulk of `session.rs`).
|
|
||||||
- **UI.** Replace the ratatui TUI with a **local web app** (Gin backend +
|
|
||||||
browser). This is the surface that must "look good."
|
|
||||||
- **AI.** AI is a first-class participant from the start, not a later add-on.
|
|
||||||
|
|
||||||
**Deferred for v1:**
|
|
||||||
|
|
||||||
- The AI **reviewer** role (session-end reflection). The three live roles ship
|
|
||||||
first; the reviewer returns as **M7 — Reflection**, which closes the loop.
|
|
||||||
- Privileged enforcement (guardian, IPC, nftables, delayed admin) — same Stage 2
|
|
||||||
boundary as the original spec. This is the path toward the **entry gate**
|
|
||||||
(see "Destination" in the Roadmap) and is deliberately the last milestone.
|
|
||||||
|
|
||||||
## Process Model
|
|
||||||
|
|
||||||
A single Go binary, `antidriftd`, runs as a **local daemon** and owns all
|
|
||||||
state. The **browser** is its face.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ antidriftd (one Go process) │
|
|
||||||
│ │
|
|
||||||
│ web (Gin) ──HTTP + SSE──▶ browser UI │
|
|
||||||
│ │ │
|
|
||||||
│ session ── statemachine ── domain │
|
|
||||||
│ │ │
|
|
||||||
│ store (snapshot + audit log) │
|
|
||||||
│ evidence (xdotool/X11) │
|
|
||||||
│ ai (CLI backend, async workers) │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- The daemon holds live state **in memory** as the single source of truth.
|
|
||||||
- It persists a **snapshot** on every state change (crash/restart recovery).
|
|
||||||
- It appends every significant event to an **append-only audit log** (the
|
|
||||||
tamper-evident, hash-chained trail — for audit and later review, not for
|
|
||||||
state reconstruction).
|
|
||||||
- The browser is stateless: it renders what the daemon pushes over Server-Sent
|
|
||||||
Events (SSE) and POSTs user actions back. No business logic in the browser.
|
|
||||||
|
|
||||||
### Why snapshot instead of replay
|
|
||||||
|
|
||||||
The original Rust design reconstructs all state by replaying the entire event
|
|
||||||
log on startup and re-validating every transition, with a dedicated test per
|
|
||||||
illegal sequence. That is correct and tamper-aware, but it is the single
|
|
||||||
largest source of code and token weight. A snapshot of current state plus an
|
|
||||||
append-only audit trail gives the same recoverability and keeps tamper evidence
|
|
||||||
on the log, at a fraction of the code. State-machine *correctness* is still
|
|
||||||
enforced — by the pure transition functions at the point of transition, tested
|
|
||||||
directly — just not re-litigated on every startup.
|
|
||||||
|
|
||||||
## Architecture: Ports Around a Decision Core
|
|
||||||
|
|
||||||
AntiDrift is a **focus brain**: a decision core surrounded by pluggable
|
|
||||||
interfaces (ports) to the outside world. This is a ports-and-adapters
|
|
||||||
(hexagonal) architecture, and it is the organizing principle the whole system
|
|
||||||
grows along. New capability is almost always "a new port + adapter," not a
|
|
||||||
change to the core.
|
|
||||||
|
|
||||||
The core is layered, and the layering is load-bearing:
|
|
||||||
|
|
||||||
- **Skeleton — deterministic, no I/O** (`domain` + `statemachine`). The rails.
|
|
||||||
Owns what moves are *legal*. The original spec's safety property, "no
|
|
||||||
unchosen transitions," lives here: the system can only ever be in a legal
|
|
||||||
state, reached by a legal move.
|
|
||||||
- **Nervous system — the orchestrator** (`session.Controller`). The single hub.
|
|
||||||
Holds the in-memory state-of-truth, routes signals between ports and the
|
|
||||||
skeleton, persists snapshots, appends to the audit log, and broadcasts.
|
|
||||||
Everything connects through here.
|
|
||||||
- **Cortex — the advisor** (the LLM, via the `ai` port). Powerful *judgment* at
|
|
||||||
the decision points the state machine exposes — sharpen this commitment, is
|
|
||||||
this window drift, nudge me. It informs and proposes; **it can never force an
|
|
||||||
illegal transition.** The LLM is the most powerful adapter, not the kernel.
|
|
||||||
|
|
||||||
"The brain" is all three together. Critically, the state machine — not the LLM
|
|
||||||
— owns transitions; the LLM acts only within the rails the skeleton enforces.
|
|
||||||
|
|
||||||
### Ports
|
|
||||||
|
|
||||||
Each port is a small Go interface with one real adapter (and a fake for tests).
|
|
||||||
|
|
||||||
| Port | Interface | "Answers" | Adapter(s) | Milestone |
|
|
||||||
| ---- | --------- | --------- | ---------- | --------- |
|
|
||||||
| Activity | `evidence.Source` | What am I doing right now? | X11 / xgbutil (was xdotool) | M1 |
|
|
||||||
| Advisor | `ai.Assistant` (`Coach`/`JudgeDrift`/`Nudge`) | What's the smart call here? | `claude`/`codex` CLI | M2–M3 |
|
|
||||||
| Tasks | `tasks.Provider` | What *should* I be doing? | Amazing Marvin (existing `ampy` + marvin MCP) | deferred (M5) |
|
|
||||||
| Knowledge | `knowledge.Source` | Who am I; what are my priorities? | PKM / files | deferred (M6) |
|
|
||||||
| Enforcement / Gate | `enforce.Guard` | Make drift cost something — ultimately, gate the machine on a declared intention | window-minimize (legacy `minimize_other`) → nftables/guardian → entry gate | deferred (M8) |
|
|
||||||
| UI | `web` | Show me; take my input | Gin + browser over SSE | M0, ongoing |
|
|
||||||
|
|
||||||
Persistence (`store`) is infrastructure shared by the orchestrator, not a port.
|
|
||||||
|
|
||||||
The `tasks`, `knowledge`, and `enforce` ports are **named now but built later**
|
|
||||||
— defining them keeps the architecture coherent without expanding near-term
|
|
||||||
scope. We resist designing their interfaces in detail until the milestone that
|
|
||||||
builds them, to avoid speculative abstraction (YAGNI). M1 ships the first real
|
|
||||||
port end-to-end (`evidence.Source` + X11 adapter + fake), establishing the
|
|
||||||
pattern every later port copies.
|
|
||||||
|
|
||||||
## Package Layout
|
|
||||||
|
|
||||||
| Package | Ports from | Size | Purpose |
|
|
||||||
| -------------- | ------------------------ | ------ | ------- |
|
|
||||||
| `domain` | `domain.rs` | small | Commitment, PolicySnapshot, runtime/commitment states, AllowedContext, EnforcementLevel, validation |
|
|
||||||
| `statemachine` | `state_machine.rs` | small | Pure transition functions (1:1 port) |
|
|
||||||
| `session` | reimagined `session.rs` | medium | In-memory controller; drives transitions, snapshots, audit appends; no replay validation |
|
|
||||||
| `store` | `event_log.rs` | small | Snapshot file (current state) + append-only hash-chained audit JSONL |
|
|
||||||
| `evidence` | `window/*` + `context.rs`| small | Active-window snapshot (xdotool/X11), evidence health, allowed-context matching |
|
|
||||||
| `ai` | new | small | `Coach` / `JudgeDrift` / `Nudge` behind one interface; CLI backend |
|
|
||||||
| `web` | new (replaces TUI) | medium | Gin routes, SSE stream, static browser UI |
|
|
||||||
| `tasks` | new (deferred, M5) | small | `Provider` port over current to-do items; Amazing Marvin adapter |
|
|
||||||
| `knowledge` | new (deferred, M6) | small | `Source` port over personal priorities / about-me context |
|
|
||||||
| `enforce` | `window/*` (minimize) | small | `Guard` port; make drift cost something (window-minimize → nftables/guardian) |
|
|
||||||
|
|
||||||
Design constraint: every package stays small and single-purpose so an AI edit
|
|
||||||
loads one focused file, not a monolith. This is the concrete mechanism for the
|
|
||||||
token-efficiency goal.
|
|
||||||
|
|
||||||
## AI Integration
|
|
||||||
|
|
||||||
AI is reached through one narrow interface with a single CLI backend to start:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Assistant interface {
|
|
||||||
// Planning: turn a vague intent into a concrete commitment.
|
|
||||||
Coach(ctx context.Context, intent string) (domain.Commitment, error)
|
|
||||||
// Live: is the current window on-task for this commitment?
|
|
||||||
JudgeDrift(ctx context.Context, c domain.Commitment, w evidence.WindowSnapshot) (Verdict, error)
|
|
||||||
// Ambient: periodic check-in based on recent activity.
|
|
||||||
Nudge(ctx context.Context, c domain.Commitment, recent []evidence.WindowSnapshot) (string, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Backend (v1):** shell out to `claude`/`codex` with a strict prompt that
|
|
||||||
demands JSON output. Reuses existing CLI auth; no API key plumbing.
|
|
||||||
- **Latency containment** (the CLI is slow, ~seconds, and AI is in the live hot
|
|
||||||
path): all AI calls run in **background goroutines**; the UI never blocks.
|
|
||||||
Drift judgments are **debounced** (no faster than ~10s) and **cached per
|
|
||||||
(commitment, window-class)** so the same window is not re-judged. The UI
|
|
||||||
shows a pending state and updates via SSE when a verdict lands.
|
|
||||||
- **Swap path:** the interface boundary lets an Anthropic API backend (faster,
|
|
||||||
structured, prompt-cached) drop in later without touching callers. Not built
|
|
||||||
in v1.
|
|
||||||
|
|
||||||
The three live roles ship first: planning **coach**, live **drift
|
|
||||||
interceptor**, ambient **nudge**. The reviewer is deferred. The advisor sits at
|
|
||||||
the **cortex** layer of the decision core (see "Architecture: Ports Around a
|
|
||||||
Decision Core"): it proposes and judges at the decision points the state
|
|
||||||
machine exposes, but never owns a transition.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
Each milestone is independently shippable and gets its own spec → plan → build
|
|
||||||
cycle. M0–M4 build the core plus the first two ports (activity, advisor) and the
|
|
||||||
UI; M5–M8 add the remaining ports and close the loop, each a small interface +
|
|
||||||
adapter following the pattern M1 establishes.
|
|
||||||
|
|
||||||
**Destination — the gate-first loop.** The end state is an OS-level focus loop:
|
|
||||||
the **Guard** checks for a declared intention *before* the machine is usable,
|
|
||||||
the user commits, works under monitoring, and every cycle ends in
|
|
||||||
**reflection** before returning to the gate (gate → plan → work → reflect →
|
|
||||||
gate). The earlier milestones build "track and advise"; the later ones turn
|
|
||||||
that into "you don't drift in the first place." We get there incrementally — the
|
|
||||||
Guard's privileged entry-gate behavior is deliberately the last and heaviest
|
|
||||||
step (M8), and everything before it is valuable standalone. The runtime state
|
|
||||||
machine already mirrors this loop: `locked` is the gate, `planning`/`active` are
|
|
||||||
declare-and-work, `review` is reflection.
|
|
||||||
|
|
||||||
- **M0 — Walking skeleton.** Daemon + Gin + minimal browser UI; port `domain` +
|
|
||||||
`statemachine`; snapshot persistence; manual commitment → timebox → end.
|
|
||||||
Proves the full stack end-to-end. No AI, no window tracking.
|
|
||||||
- **M1 — Evidence & audit.** X11 (xgbutil) active-window tracking via the
|
|
||||||
`evidence.Source` port, evidence health, per-window time stats, append-only
|
|
||||||
hash-chained audit log, live SSE updates. Establishes the port pattern.
|
|
||||||
- **M2 — AI planning coach.** `ai` port + CLI backend; "sharpen this
|
|
||||||
commitment" in the Planning view.
|
|
||||||
- **M3 — Drift interceptor + ambient nudge.** Allowed-context matching + live
|
|
||||||
AI drift judgment (debounced/cached) + violation friction UI.
|
|
||||||
- **M4 — Look good.** A real design pass on the web UI.
|
|
||||||
- **M5 — Tasks port.** `tasks.Provider` over current to-do items; Amazing Marvin
|
|
||||||
adapter. Pull the day's commitments from real tasks.
|
|
||||||
- **M6 — Knowledge port.** `knowledge.Source` over personal priorities and
|
|
||||||
about-me context, feeding the advisor richer grounding.
|
|
||||||
- **M7 — Reflection.** The deferred AI **reviewer** role, promoted into the main
|
|
||||||
loop: a session-end reflection that reads the audit trail and per-window stats
|
|
||||||
(built in M1) to summarize what happened and feed the next planning cycle.
|
|
||||||
Closes the "Focus OS Reflection" step and makes the loop self-reinforcing.
|
|
||||||
- **M8 — Enforcement & gate.** `enforce.Guard`: make drift cost something,
|
|
||||||
starting with window-minimize (porting legacy `minimize_other`) and the
|
|
||||||
nftables/DNS path, building toward the **entry gate** — the Guard checking for
|
|
||||||
a declared intention before the machine is usable. The privileged guardian
|
|
||||||
process, root-owned IPC, and break-glass keep the original Stage 2 threat
|
|
||||||
boundary and are the final, heaviest step.
|
|
||||||
|
|
||||||
The first sub-project to brainstorm and spec in detail is **M0**.
|
|
||||||
|
|
||||||
## Repo Strategy
|
|
||||||
|
|
||||||
- New Go module at the repository root.
|
|
||||||
- Move the existing Rust into a `legacy/` directory (or a `rust` branch) so it
|
|
||||||
remains available as reference while the Go code becomes the front door.
|
|
||||||
|
|
||||||
## Out of Scope (v1)
|
|
||||||
|
|
||||||
"v1" here means the first shippable arc, **M0–M4** (core + activity/advisor
|
|
||||||
ports + UI). The items below are deferred past it; some are now named ports with
|
|
||||||
their own later milestones (see Roadmap), others remain fully out of scope.
|
|
||||||
|
|
||||||
- **Tasks, knowledge, and enforcement ports** — named in the architecture and
|
|
||||||
slotted as M5–M8, but not built in v1. The `enforce.Guard` port starts with
|
|
||||||
window-minimize and builds toward the entry gate; its **privileged** adapters
|
|
||||||
(guardian process, root-owned Unix socket IPC, nftables/DNS domain blocking,
|
|
||||||
delayed admin, break-glass) remain out of scope until M8 and keep the original
|
|
||||||
Stage 2 threat boundary.
|
|
||||||
- AI reviewer / session-end reflection — now scheduled as **M7 — Reflection**.
|
|
||||||
- Wayland compositor adapters beyond the existing degraded reporting.
|
|
||||||
- Planner/project model and outcome writeback (beyond the M5 tasks port).
|
|
||||||
- Presence sensing.
|
|
||||||
|
|
||||||
These remain governed by `commitment-os-design.md` and may return as later
|
|
||||||
milestones.
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
# M0 — Walking Skeleton Design
|
|
||||||
|
|
||||||
Date: 2026-05-31
|
|
||||||
|
|
||||||
Parent design: `2026-05-31-go-focus-os-design.md`
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
M0 proves the entire Go stack end-to-end with the smallest possible surface: a
|
|
||||||
single Go daemon (`antidriftd`) that serves a local web UI, drives one manual
|
|
||||||
commitment through the ported state machine, and persists a snapshot that
|
|
||||||
survives restart.
|
|
||||||
|
|
||||||
M0 explicitly excludes AI, active-window tracking, and the hash-chained audit
|
|
||||||
log (those arrive in M1+). What M0 establishes is the real architecture — the
|
|
||||||
daemon process model, the ported domain/state-machine, snapshot persistence,
|
|
||||||
and the SSE sync channel — so later milestones add features to a working spine
|
|
||||||
rather than scaffolding.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
In scope:
|
|
||||||
|
|
||||||
- Port `domain` (Commitment, runtime/commitment states, EnforcementLevel,
|
|
||||||
minimal PolicySnapshot, validation).
|
|
||||||
- Port `statemachine` (pure runtime/commitment transition functions).
|
|
||||||
- `session.Controller`: in-memory state of truth behind a mutex; snapshot on
|
|
||||||
every change.
|
|
||||||
- `store`: snapshot load/save as JSON.
|
|
||||||
- `web`: Gin server with the M0 HTTP surface and an SSE stream.
|
|
||||||
- A single-page vanilla-JS browser UI covering the M0 state flow.
|
|
||||||
- Unit tests for domain, statemachine, session; httptest tests for web.
|
|
||||||
|
|
||||||
Out of scope (deferred):
|
|
||||||
|
|
||||||
- AI of any kind (M2+).
|
|
||||||
- Active-window tracking and evidence health (M1).
|
|
||||||
- Hash-chained append-only audit log (M1).
|
|
||||||
- Allowed-context matching and violation friction (M3).
|
|
||||||
- Transition (break) flow, admin override, planner — full machine edges beyond
|
|
||||||
the M0 subset.
|
|
||||||
- Visual design polish (M4).
|
|
||||||
|
|
||||||
## State Flow
|
|
||||||
|
|
||||||
M0 exercises a subset of the full runtime state machine:
|
|
||||||
|
|
||||||
```
|
|
||||||
Locked ──Plan──▶ Planning ──Start──▶ Active ──Complete / timebox expiry──▶ Review ──End──▶ Locked
|
|
||||||
```
|
|
||||||
|
|
||||||
All transitions go through the ported pure transition functions, so behavior is
|
|
||||||
correct from day one even though fewer edges are exercised. Mapping to the
|
|
||||||
ported actions:
|
|
||||||
|
|
||||||
- Locked → Planning: `EnterPlanning`
|
|
||||||
- Planning → Active: `Activate { policy_accepted: true }` (commitment also
|
|
||||||
`Activate`s draft → active)
|
|
||||||
- Active → Review: `CompleteForReview` (triggered by user "Complete" or by
|
|
||||||
server-side timebox expiry)
|
|
||||||
- Review → Locked: `EndWorkPeriod`
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### `domain`
|
|
||||||
|
|
||||||
Direct port of the valuable Rust types:
|
|
||||||
|
|
||||||
- `Commitment` (id, createdAt, source, next action, success condition, timebox
|
|
||||||
seconds, state) with `NewManual(...)` validation: non-empty next action,
|
|
||||||
non-empty success condition, non-zero timebox.
|
|
||||||
- `RuntimeState` (Locked, Planning, Active, Transition, Review, AdminOverride)
|
|
||||||
and `CommitmentState` (Draft, Active, Paused, Completed, Abandoned, Violated)
|
|
||||||
— full enums, even though M0 uses a subset, so later milestones need no
|
|
||||||
changes here.
|
|
||||||
- `EnforcementLevel` (Observe, Warn, Block, Locked).
|
|
||||||
- `PolicySnapshot`: minimal — enough to represent the accepted policy that gates
|
|
||||||
`Activate`. Full enforcement fields can stay zero/empty in M0.
|
|
||||||
- IDs use UUIDv7 (or equivalent monotonic unique id); JSON tags use snake_case
|
|
||||||
to match the existing on-disk vocabulary.
|
|
||||||
|
|
||||||
### `statemachine`
|
|
||||||
|
|
||||||
Port of `state_machine.rs`:
|
|
||||||
|
|
||||||
- `TransitionRuntime(current RuntimeState, action RuntimeAction) (RuntimeState, error)`
|
|
||||||
- `TransitionCommitment(current CommitmentState, action CommitmentAction) (CommitmentState, error)`
|
|
||||||
- Illegal transitions return a typed error identifying current state + action.
|
|
||||||
Pure functions, no I/O.
|
|
||||||
|
|
||||||
### `session.Controller`
|
|
||||||
|
|
||||||
- Holds `runtimeState` and `activeCommitment` in memory, guarded by a
|
|
||||||
`sync.Mutex`, as the single source of truth.
|
|
||||||
- Methods: `EnterPlanning()`, `StartManualCommitment(nextAction, successCond
|
|
||||||
string, timebox time.Duration)`, `Complete()`, `End()`.
|
|
||||||
- Each method applies the relevant transition(s), updates in-memory state, and
|
|
||||||
persists a snapshot via `store`. On any transition error, state is left
|
|
||||||
unchanged and the error is returned.
|
|
||||||
- Exposes a read method returning a snapshot-shaped view for broadcasting.
|
|
||||||
|
|
||||||
### `store`
|
|
||||||
|
|
||||||
- Snapshot is the current state: runtime state + active commitment (+ deadline).
|
|
||||||
- `Load(path) (Snapshot, error)` — missing file yields a default `Locked`
|
|
||||||
snapshot, not an error.
|
|
||||||
- `Save(path, Snapshot) error` — atomic write (temp file + rename) to
|
|
||||||
`~/.antidrift/state.json`, creating the directory if needed.
|
|
||||||
- No hash chaining in M0; that belongs to the M1 audit log.
|
|
||||||
|
|
||||||
### `web`
|
|
||||||
|
|
||||||
- Gin server bound to `localhost:7777`.
|
|
||||||
- Holds the `session.Controller` and an SSE broadcaster (set of subscriber
|
|
||||||
channels).
|
|
||||||
- Each mutating handler: acquire controller mutex → apply transition → persist
|
|
||||||
snapshot → broadcast new state to all SSE subscribers → return.
|
|
||||||
|
|
||||||
## Daemon Behavior
|
|
||||||
|
|
||||||
On start, `antidriftd`:
|
|
||||||
|
|
||||||
1. Loads the snapshot (or starts `Locked`).
|
|
||||||
2. If the loaded state is `Active` with a future deadline, re-arms the expiry
|
|
||||||
timer; if the deadline has already passed, transitions to `Review`.
|
|
||||||
3. Starts Gin on `localhost:7777`.
|
|
||||||
4. Attempts to open the default browser at that URL (best-effort; logs and
|
|
||||||
continues if it fails).
|
|
||||||
|
|
||||||
Timebox expiry is **server-authoritative**: on entering `Active`, the daemon
|
|
||||||
arms a `time.AfterFunc` at the deadline that fires `Active → Review` and
|
|
||||||
broadcasts. The browser countdown is cosmetic and derived from the deadline
|
|
||||||
timestamp in the state payload.
|
|
||||||
|
|
||||||
## HTTP Surface
|
|
||||||
|
|
||||||
| Method | Route | Body | Effect |
|
|
||||||
| ------ | ------------- | ------------------------------------------------- | ------ |
|
|
||||||
| GET | `/` | — | Serves the single-page UI |
|
|
||||||
| GET | `/events` | — | SSE stream; emits the current state immediately, then on every change |
|
|
||||||
| POST | `/planning` | — | Locked → Planning |
|
|
||||||
| POST | `/commitment` | `{next_action, success_condition, timebox_secs}` | Planning → Active (validates; 400 on invalid) |
|
|
||||||
| POST | `/complete` | — | Active → Review |
|
|
||||||
| POST | `/end` | — | Review → Locked |
|
|
||||||
|
|
||||||
State payload (SSE `data:` and POST responses) is JSON:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"runtime_state": "active",
|
|
||||||
"commitment": {
|
|
||||||
"next_action": "Port the domain package",
|
|
||||||
"success_condition": "domain tests pass",
|
|
||||||
"timebox_secs": 1500,
|
|
||||||
"deadline_unix_secs": 1748725200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`commitment` is null when there is no active commitment. Invalid transitions
|
|
||||||
requested via HTTP return 409 (illegal transition) or 400 (invalid input); state
|
|
||||||
is unchanged.
|
|
||||||
|
|
||||||
## Browser UI
|
|
||||||
|
|
||||||
Single HTML page served from `/`, using vanilla JavaScript with no build step
|
|
||||||
(no bundler, no framework) to keep it dependency-free and token-light. It:
|
|
||||||
|
|
||||||
- Subscribes to `/events` via `EventSource`.
|
|
||||||
- Renders exactly one view based on `runtime_state`:
|
|
||||||
- **Locked** — status + a "Plan" button (POST `/planning`).
|
|
||||||
- **Planning** — a form with next action, success condition, and minutes;
|
|
||||||
"Start" is disabled until all three are valid (POST `/commitment`).
|
|
||||||
- **Active** — next action, success condition, a live countdown derived from
|
|
||||||
`deadline_unix_secs`, and a "Complete" button (POST `/complete`).
|
|
||||||
- **Review** — a short summary of the just-ended commitment and an "End"
|
|
||||||
button (POST `/end`).
|
|
||||||
- Is a pure renderer of pushed state; it holds no authoritative state of its
|
|
||||||
own. Styling is clean and legible but not the focus — the real visual pass is
|
|
||||||
M4.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `domain`: validation rejects empty next action / success condition / zero
|
|
||||||
timebox; JSON round-trips with snake_case tags.
|
|
||||||
- `statemachine`: legal transitions for the M0 subset succeed; representative
|
|
||||||
illegal transitions (e.g. Locked → Active) return typed errors. Port the
|
|
||||||
intent of the existing Rust tests.
|
|
||||||
- `session`: planning → start → complete → end happy path drives the expected
|
|
||||||
states; snapshot save/load round-trips and restores the controller.
|
|
||||||
- `web`: `httptest` checks that POST `/planning` then POST `/commitment` moves
|
|
||||||
the controller to Active, that `/commitment` with invalid input returns 400,
|
|
||||||
and that `/events` emits a state payload.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
`go run ./cmd/antidriftd` opens a browser; the user creates a commitment,
|
|
||||||
watches the timebox count down, completes it (or lets it expire into Review),
|
|
||||||
and ends the session back to Locked. Killing and restarting the daemon restores
|
|
||||||
the persisted state from `~/.antidrift/state.json`. `go test ./...` passes.
|
|
||||||
|
|
||||||
## Repo Setup (first task of M0)
|
|
||||||
|
|
||||||
- Initialize the Go module at the repository root.
|
|
||||||
- Move the existing Rust sources into `legacy/` so they remain available as
|
|
||||||
reference (Cargo.toml, src/, etc.), keeping the shared `docs/` at the root.
|
|
||||||
- Code layout: `cmd/antidriftd/` (main), `internal/domain`,
|
|
||||||
`internal/statemachine`, `internal/session`, `internal/store`,
|
|
||||||
`internal/web` (with the static UI under `internal/web/static`).
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
# M1 — Evidence & Audit Design
|
|
||||||
|
|
||||||
Date: 2026-05-31
|
|
||||||
|
|
||||||
Parent design: `2026-05-31-go-focus-os-design.md`
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
M1 gives the daemon eyes and a memory. It adds a continuous active-window
|
|
||||||
sensor, a two-tier evidence store, and live updates of what you are doing — then
|
|
||||||
seals each finished session into a tamper-evident, hash-chained audit trail.
|
|
||||||
|
|
||||||
M1 is **observe & record only**. It makes no judgment about whether the current
|
|
||||||
window is on-task; that is the advisor's job in M3. M1 is honest
|
|
||||||
instrumentation: the trustworthy foundation that later milestones read from.
|
|
||||||
|
|
||||||
In the architecture of the parent design, M1 builds the first real **port** end
|
|
||||||
to end — the activity port (`evidence.Source`) with its X11 adapter and a fake
|
|
||||||
for tests — establishing the interface + adapter + fake pattern every later port
|
|
||||||
copies.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
In scope:
|
|
||||||
|
|
||||||
- `evidence` package: an X11 (xgbutil) active-window sensor behind a `Source`
|
|
||||||
interface, pushing a `WindowSnapshot` on every focus change; evidence health;
|
|
||||||
the legacy title-scrubbing regex as a tested `ScrubTitle` function.
|
|
||||||
- `store` extensions: a per-session raw focus-event log (`sessions/<id>.jsonl`,
|
|
||||||
appended live, pruned after a retention window) and a permanent hash-chained
|
|
||||||
audit log (`audit.jsonl`, one linked `SessionSummary` per completed session).
|
|
||||||
- `session.Controller` extensions: in-memory per-session evidence stats, an
|
|
||||||
injectable clock, focus accumulation while `Active`, crash-recovery replay,
|
|
||||||
and writing the hashed summary at session end.
|
|
||||||
- `web` extensions: the SSE payload carries an `evidence` object; the Active and
|
|
||||||
Review views surface current window, per-bucket time, context-switch count,
|
|
||||||
and an evidence-health indicator. A broadcast callback registered on the
|
|
||||||
controller so every state change (focus, expiry, user action) fans out over
|
|
||||||
one path.
|
|
||||||
- Unit tests for `evidence` (scrub + interface), `store` (audit chain +
|
|
||||||
evidence log), `session` (accumulation via fake source + injectable clock),
|
|
||||||
and `web` (evidence payload over SSE).
|
|
||||||
|
|
||||||
Out of scope (deferred):
|
|
||||||
|
|
||||||
- Any AI / drift judgment / on-task vs off-task classification (M2–M3).
|
|
||||||
- Allowed-context matching and violation friction (M3).
|
|
||||||
- Enforcement of any kind, including window-minimize (M7).
|
|
||||||
- Wayland active-window support beyond degraded `Unavailable` reporting.
|
|
||||||
- Visual design polish (M4) — M1 surfaces data legibly, nothing more.
|
|
||||||
|
|
||||||
## Architecture Fit
|
|
||||||
|
|
||||||
Per the parent design's "ports around a decision core":
|
|
||||||
|
|
||||||
- **Skeleton** (`domain`, `statemachine`) is unchanged by M1.
|
|
||||||
- **Nervous system** (`session.Controller`) gains evidence ownership: it
|
|
||||||
receives sensor events, decides relevance by current runtime state,
|
|
||||||
accumulates stats, persists raw events, and writes the audit summary. It
|
|
||||||
remains the single hub.
|
|
||||||
- **Activity port** (`evidence.Source`) is new — a dumb sensor that makes no
|
|
||||||
decisions. `session` decides what each event means.
|
|
||||||
|
|
||||||
The sensor never judges; the orchestrator never talks to X11 directly. This is
|
|
||||||
the port boundary M1 exists to establish.
|
|
||||||
|
|
||||||
## The Two-Tier Evidence Model
|
|
||||||
|
|
||||||
Window focus can change dozens of times a minute. Keeping every focus event in
|
|
||||||
the permanent hash chain forever would bloat it. Discarding detail loses the
|
|
||||||
ability to compute things like context-switch frequency. M1 resolves this with
|
|
||||||
two tiers:
|
|
||||||
|
|
||||||
1. **Raw, per-session, disposable.** Every focus change is appended live to
|
|
||||||
`sessions/<session-id>.jsonl` as it happens (crash-durable). This is the
|
|
||||||
firehose: full titles, millisecond timestamps. It is the recovery source for
|
|
||||||
in-memory stats and the basis for end-of-session analytics. It is **pruned
|
|
||||||
after 30 days**.
|
|
||||||
2. **Summarized, permanent, tamper-evident.** At session end the raw stream is
|
|
||||||
rolled up into one `SessionSummary` (per-bucket totals, switch count,
|
|
||||||
duration, outcome) which is hash-chained into `audit.jsonl` and **kept
|
|
||||||
forever**. The chain is coarse — one linked entry per session — so tamper
|
|
||||||
evidence is cheap and the log stays small.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### `evidence` (new package)
|
|
||||||
|
|
||||||
A dumb X11 sensor. One long-lived xgbutil connection is opened at daemon start
|
|
||||||
and kept open for the whole process. It subscribes to `PropertyNotify` on the
|
|
||||||
root window's `_NET_ACTIVE_WINDOW` and emits a `WindowSnapshot` on every active
|
|
||||||
window change (and once immediately with the current window). It makes no
|
|
||||||
relevance decisions.
|
|
||||||
|
|
||||||
```go
|
|
||||||
type EvidenceHealth struct {
|
|
||||||
Available bool
|
|
||||||
Reason string // empty when Available; populated when not
|
|
||||||
}
|
|
||||||
|
|
||||||
type WindowSnapshot struct {
|
|
||||||
Title string // full _NET_WM_NAME (raw; used in live view + raw log)
|
|
||||||
Class string // WM_CLASS
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Real implementation `x11Source` uses xgbutil:
|
|
||||||
`ewmh.ActiveWindowGet` → `ewmh.WmNameGet` (title) + `icccm.WmClassGet` (class).
|
|
||||||
- On X failure, no active window, or unset `DISPLAY`:
|
|
||||||
`Health{Available: false, Reason: "..."}` with empty title/class. This mirrors
|
|
||||||
the legacy degraded reporting and covers Wayland implicitly.
|
|
||||||
- `ScrubTitle(string) string` is the legacy digit/percent regex
|
|
||||||
(`-?\d+([:.]\d+)+%?`) ported as its own tested function. Used to compute
|
|
||||||
bucket keys; the raw log keeps the unscrubbed title.
|
|
||||||
|
|
||||||
### `store` (extended)
|
|
||||||
|
|
||||||
Two new files beside `snapshot.go`. `store` is infrastructure, not a port.
|
|
||||||
|
|
||||||
`store/audit.go` — the permanent hash-chained log at `~/.antidrift/audit.jsonl`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type BucketTotal struct {
|
|
||||||
Class string `json:"class"`
|
|
||||||
Title string `json:"title"` // scrubbed
|
|
||||||
Seconds int64 `json:"seconds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionSummary struct {
|
|
||||||
Seq int `json:"seq"`
|
|
||||||
PrevHash string `json:"prev_hash"`
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
NextAction string `json:"next_action"`
|
|
||||||
SuccessCond string `json:"success_condition"`
|
|
||||||
Outcome string `json:"outcome"` // "completed" | "expired"
|
|
||||||
StartedUnix int64 `json:"started_unix"`
|
|
||||||
EndedUnix int64 `json:"ended_unix"`
|
|
||||||
SwitchCount int `json:"switch_count"`
|
|
||||||
Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AppendSession(path string, s SessionSummary) error
|
|
||||||
func VerifyChain(path string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
- `AppendSession` reads the last line's `Hash` as this entry's `PrevHash`
|
|
||||||
(genesis = 64 hex zeros), assigns `Seq = lastSeq + 1`, computes
|
|
||||||
`Hash = SHA-256(prevHash || canonicalJSON(fields-except-Hash))`, and appends
|
|
||||||
one JSON line atomically (append + fsync). Canonical serialization uses the
|
|
||||||
struct with `Hash` zeroed and stable field order (encoding/json is stable for
|
|
||||||
structs).
|
|
||||||
- `VerifyChain` re-walks every line: recompute each hash, confirm each
|
|
||||||
`PrevHash` equals the prior `Hash`, confirm `Seq` is contiguous. Returns an
|
|
||||||
error naming the first broken line; nil if intact or empty.
|
|
||||||
|
|
||||||
`store/evidence_log.go` — the per-session raw stream at
|
|
||||||
`~/.antidrift/sessions/<session-id>.jsonl`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type FocusEvent struct {
|
|
||||||
AtUnixMillis int64 `json:"at_unix_millis"`
|
|
||||||
Class string `json:"class"`
|
|
||||||
Title string `json:"title"` // full, unscrubbed
|
|
||||||
Available bool `json:"available"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AppendFocus(dir, sessionID string, e FocusEvent) error
|
|
||||||
func ReplaySession(dir, sessionID string) ([]FocusEvent, error)
|
|
||||||
func PruneOlderThan(dir string, age time.Duration, now time.Time) error
|
|
||||||
```
|
|
||||||
|
|
||||||
- `AppendFocus` opens the session file `O_APPEND|O_CREATE`, writes one JSON
|
|
||||||
line, closes. Creates `sessions/` if needed.
|
|
||||||
- `ReplaySession` reads all events in order (used to rebuild stats after a
|
|
||||||
crash).
|
|
||||||
- `PruneOlderThan` deletes session files whose modification time is older than
|
|
||||||
`now - age`. `now` is injected for testability.
|
|
||||||
|
|
||||||
### `session.Controller` (extended)
|
|
||||||
|
|
||||||
Gains:
|
|
||||||
|
|
||||||
- `clock func() time.Time` — injectable; defaults to `time.Now`. Used for all
|
|
||||||
accumulation and timestamps so tests are deterministic.
|
|
||||||
- `stats *EvidenceStats` — in-memory, current session only (nil when not in a
|
|
||||||
live session).
|
|
||||||
- `onChange func()` — a notification callback the web layer registers; invoked
|
|
||||||
after any state change so the browser is pushed fresh state.
|
|
||||||
- A reference to the store paths (audit file, sessions dir).
|
|
||||||
|
|
||||||
```go
|
|
||||||
type bucketKey struct{ Class, Title string } // Title is scrubbed
|
|
||||||
|
|
||||||
type EvidenceStats struct {
|
|
||||||
SessionID string
|
|
||||||
StartedUnix int64
|
|
||||||
Buckets map[bucketKey]time.Duration
|
|
||||||
SwitchCount int
|
|
||||||
Current evidence.WindowSnapshot
|
|
||||||
lastFocusAt time.Time
|
|
||||||
lastKey bucketKey
|
|
||||||
hasLast bool
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`RecordWindow(snap evidence.WindowSnapshot)`:
|
|
||||||
|
|
||||||
1. Lock. Always update `c.latestWindow = snap` (for the live indicator outside
|
|
||||||
Active).
|
|
||||||
2. If `runtimeState != Active` or `stats == nil`: fire `onChange`; return.
|
|
||||||
(Tracked for display, not accounted.)
|
|
||||||
3. `now := c.clock()`. If `hasLast`: add `now - lastFocusAt` to
|
|
||||||
`Buckets[lastKey]`. (If the prior snapshot was unavailable, `lastKey` is the
|
|
||||||
reserved `(evidence unavailable)` key.)
|
|
||||||
4. Append a `FocusEvent` to the session log.
|
|
||||||
5. Compute the new key: unavailable snapshot → reserved key
|
|
||||||
`{Class: "", Title: "(evidence unavailable)"}`; else
|
|
||||||
`{snap.Class, ScrubTitle(snap.Title)}`. If `hasLast && newKey != lastKey`,
|
|
||||||
increment `SwitchCount`.
|
|
||||||
6. Set `lastKey = newKey`, `lastFocusAt = now`, `hasLast = true`,
|
|
||||||
`Current = snap`. Fire `onChange`.
|
|
||||||
|
|
||||||
Lifecycle hooks (all under the existing mutex):
|
|
||||||
|
|
||||||
- **`StartManualCommitment`** (→ Active): mint `session_id` (UUIDv7), create
|
|
||||||
`stats` with `StartedUnix = clock()`, seed `Current`/`lastKey`/`lastFocusAt`
|
|
||||||
from `latestWindow` (so the first segment counts from start), `hasLast = true`.
|
|
||||||
Persist snapshot (now carrying `session_id`).
|
|
||||||
- **`Complete`** / **timebox expiry** (→ Review): flush final segment
|
|
||||||
(`clock() - lastFocusAt` into `lastKey`), freeze `stats` (stop accounting).
|
|
||||||
Consistent with M0's "freeze active time during review."
|
|
||||||
- **`End`** (→ Locked): build `SessionSummary` from frozen `stats`
|
|
||||||
(`Outcome` = "completed" if user-completed, "expired" if timebox fired —
|
|
||||||
tracked via a field set at the Active→Review transition), `AppendSession` to
|
|
||||||
the audit chain, then clear `stats`.
|
|
||||||
|
|
||||||
Read view: `State()` is extended to include an evidence projection (current
|
|
||||||
window, buckets sorted desc, switch count, health) for the SSE payload.
|
|
||||||
|
|
||||||
### Crash Recovery
|
|
||||||
|
|
||||||
The snapshot (`state.json`) gains `session_id` and `outcome_pending` fields.
|
|
||||||
|
|
||||||
On startup, after loading the snapshot:
|
|
||||||
|
|
||||||
- Run `store.PruneOlderThan(sessionsDir, 30*24*time.Hour, time.Now())`.
|
|
||||||
- If runtime state is `Active` with a `session_id`: `ReplaySession` the raw log
|
|
||||||
and rebuild `EvidenceStats` exactly (sum segments between consecutive events;
|
|
||||||
the final open segment resumes from the last event's timestamp). The raw log
|
|
||||||
is the recovery source of truth for stats; the snapshot only points at it.
|
|
||||||
- The M0 deadline re-arm / expire-to-Review behavior is unchanged and runs
|
|
||||||
after stats are rebuilt.
|
|
||||||
|
|
||||||
### `web` (extended)
|
|
||||||
|
|
||||||
- At startup the `Server` registers `ctrl.SetOnChange(func(){ broadcast() })`.
|
|
||||||
Every state change — focus update, timebox expiry, user action — flows out
|
|
||||||
through this single path. This also moves M0's expiry broadcast onto the same
|
|
||||||
callback rather than an inline broadcast.
|
|
||||||
- The SSE/POST payload gains an `evidence` object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"runtime_state": "active",
|
|
||||||
"commitment": { "next_action": "...", "success_condition": "...",
|
|
||||||
"timebox_secs": 1500, "deadline_unix_secs": 1748725200 },
|
|
||||||
"evidence": {
|
|
||||||
"available": true,
|
|
||||||
"reason": "",
|
|
||||||
"current": { "class": "code", "title": "m1 design - antidrift" },
|
|
||||||
"switch_count": 7,
|
|
||||||
"buckets": [
|
|
||||||
{ "class": "code", "title": "antidrift", "seconds": 540 },
|
|
||||||
{ "class": "firefox", "title": "docs", "seconds": 120 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`evidence` is null when there is no live session (Locked/Planning). In Review it
|
|
||||||
carries the frozen stats.
|
|
||||||
|
|
||||||
### Browser UI (data, not polish)
|
|
||||||
|
|
||||||
The page stays a pure renderer of pushed state. Additions:
|
|
||||||
|
|
||||||
- **Active view:** current window line (`class · title`); a per-bucket time
|
|
||||||
breakdown list (sorted desc, `mm:ss`); the context-switch count; an
|
|
||||||
evidence-health pill — green "tracking" when available, amber
|
|
||||||
"evidence unavailable: <reason>" when not.
|
|
||||||
- **Review view:** the session summary about to be hashed — total active time,
|
|
||||||
switch count, top buckets.
|
|
||||||
- Visual treatment is minimal and legible; the real design pass is M4.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
X server ──PropertyNotify──▶ evidence.x11Source.Watch (goroutine)
|
|
||||||
│ onChange(WindowSnapshot)
|
|
||||||
▼
|
|
||||||
session.Controller.RecordWindow
|
|
||||||
│ (accumulate if Active) │ append raw event
|
|
||||||
▼ ▼
|
|
||||||
EvidenceStats (memory) sessions/<id>.jsonl
|
|
||||||
│ onChange()
|
|
||||||
▼
|
|
||||||
web broadcast ──SSE──▶ browser
|
|
||||||
...
|
|
||||||
End: SessionSummary ──hash-chain──▶ audit.jsonl (permanent)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- **Evidence unavailable** (X error, no active window, Wayland): snapshot has
|
|
||||||
`Health.Available = false`; time during the gap accrues to the reserved
|
|
||||||
`(evidence unavailable)` bucket so totals always equal wall-clock duration.
|
|
||||||
The watch goroutine logs and continues; it never crashes the daemon.
|
|
||||||
- **Session log append failure:** logged; accounting continues in memory (the
|
|
||||||
raw log is best-effort detail, not the state of truth). Crash recovery for
|
|
||||||
that session would be incomplete, which is acceptable for disposable detail.
|
|
||||||
- **Audit append failure at End:** logged and surfaced; the transition to Locked
|
|
||||||
still completes (state integrity over audit completeness). A retry-on-next-
|
|
||||||
start is out of scope.
|
|
||||||
- **Corrupt audit chain:** `VerifyChain` reports the first broken line; M1 only
|
|
||||||
exposes verification, it does not auto-repair.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `evidence`: `ScrubTitle` table tests porting the legacy regex cases
|
|
||||||
(percentages, ratios, leading sign, plain titles untouched). The `x11Source`
|
|
||||||
sits behind `Source`; a `//go:build` integration smoke test queries the live
|
|
||||||
server and is skipped when `DISPLAY` is unset.
|
|
||||||
- `store/audit`: append N summaries then `VerifyChain` passes; genesis
|
|
||||||
`PrevHash` is 64 zeros; `Seq` is contiguous; a tamper test mutates a middle
|
|
||||||
line's field and asserts `VerifyChain` names that line.
|
|
||||||
- `store/evidence_log`: `AppendFocus` then `ReplaySession` round-trips event
|
|
||||||
order/values; `PruneOlderThan` deletes only files older than the cutoff
|
|
||||||
(modification time controlled).
|
|
||||||
- `session`: a `fakeSource` plus injectable `clock` drive scripted focus
|
|
||||||
sequences with controlled timestamps. Assert: bucket totals and switch count;
|
|
||||||
events outside Active are not accounted; unavailable snapshots accrue to the
|
|
||||||
reserved bucket; crash-replay rebuilds identical stats; `End` writes a
|
|
||||||
summary whose buckets/switch-count match and which extends the chain.
|
|
||||||
- `web`: httptest confirms `/events` emits an evidence-bearing payload and that
|
|
||||||
a simulated focus change pushes an updated payload.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
`go run ./cmd/antidriftd`: start a commitment, switch between a few windows, and
|
|
||||||
watch the Active view update live with the current window, per-app time, and
|
|
||||||
switch count. Let it complete (or expire) into Review and see the session
|
|
||||||
summary. End it; `audit.jsonl` gains one hash-chained line and `VerifyChain`
|
|
||||||
passes. Kill the daemon mid-session and restart: the per-window stats are
|
|
||||||
rebuilt from the session log and tracking resumes. Session files older than 30
|
|
||||||
days are gone on startup. `go test ./...` passes.
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- Create: `internal/evidence/evidence.go` (types, `Source`, `ScrubTitle`)
|
|
||||||
- Create: `internal/evidence/x11.go` (`x11Source`, xgbutil; build-tagged)
|
|
||||||
- Create: `internal/evidence/evidence_test.go`
|
|
||||||
- Create: `internal/store/audit.go`, `internal/store/evidence_log.go`
|
|
||||||
- Create: `internal/store/audit_test.go`, `internal/store/evidence_log_test.go`
|
|
||||||
- Modify: `internal/store/store.go` (snapshot gains `session_id`,
|
|
||||||
`outcome_pending`)
|
|
||||||
- Modify: `internal/session/session.go` (clock, stats, RecordWindow, lifecycle
|
|
||||||
hooks, onChange, recovery), `internal/session/session_test.go`
|
|
||||||
- Modify: `internal/web/web.go` (SetOnChange wiring, evidence payload),
|
|
||||||
`internal/web/web_test.go`, `internal/web/static/index.html`
|
|
||||||
- Modify: `cmd/antidriftd/main.go` (construct `evidence.Source`, start `Watch`,
|
|
||||||
wire to controller)
|
|
||||||
- Modify: `go.mod` (add `github.com/jezek/xgb`, `github.com/jezek/xgbutil`)
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
# M2 — AI Planning Coach — Design
|
|
||||||
|
|
||||||
Date: 2026-05-31
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
M2 adds the first AI capability to AntiDrift: a **planning coach**. In the
|
|
||||||
Planning view, the user types one rough intent ("work on the quarterly report"),
|
|
||||||
presses **Sharpen**, and an AI coach proposes a structured commitment —
|
|
||||||
`next_action`, `success_condition`, and a `timebox` — that pre-fills the existing
|
|
||||||
three Planning inputs for the user to edit and accept.
|
|
||||||
|
|
||||||
This establishes the `ai` port (the **cortex** layer of the decision core) and
|
|
||||||
the CLI backend, the pattern every later AI role (drift interceptor, nudge,
|
|
||||||
reflection) will reuse. The coach **proposes**; the user still drives the
|
|
||||||
existing `/commitment` transition. The LLM never owns a state transition.
|
|
||||||
|
|
||||||
AI is **strictly additive**: if the coach is unavailable, slow, or returns
|
|
||||||
garbage, the three manual Planning inputs remain fully usable. This mirrors the
|
|
||||||
evidence-health degradation pattern established in M1.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
**In scope (M2):**
|
|
||||||
|
|
||||||
- A new `ai` package with a pluggable CLI **backend** abstraction and **two real
|
|
||||||
adapters from day one: `claude` and `codex`**.
|
|
||||||
- A backend-agnostic **`Coach`** capability that turns a free-text intent into a
|
|
||||||
validated `Proposal`.
|
|
||||||
- Async, SSE-driven delivery: the coach runs in a background goroutine; the UI
|
|
||||||
shows a pending state and updates when the proposal lands.
|
|
||||||
- Graceful degradation on every failure path (missing CLI, timeout, malformed
|
|
||||||
output, no backend wired).
|
|
||||||
- Planning-view UI: an intent box + Sharpen button that pre-fills the existing
|
|
||||||
inputs from the proposal.
|
|
||||||
|
|
||||||
**Out of scope (deferred):**
|
|
||||||
|
|
||||||
- The `JudgeDrift` and `Nudge` roles — they join the `ai` interface in **M3**.
|
|
||||||
M2 builds only `Coach` (YAGNI).
|
|
||||||
- An Anthropic API backend — the interface boundary allows it later without
|
|
||||||
touching callers; not built now.
|
|
||||||
- Any change to the commitment/runtime state machine. The coach produces a
|
|
||||||
draft; activation still goes through the existing `StartManualCommitment`
|
|
||||||
path.
|
|
||||||
- Persisting the proposal. It is ephemeral pre-commitment advice (see
|
|
||||||
"Ephemeral state").
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
M2 follows the established ports-and-adapters shape. The `ai` package is the new
|
|
||||||
**Advisor** port; `claude` and `codex` are its adapters; `session.Controller`
|
|
||||||
(the nervous system) orchestrates the async call and broadcasts; the browser
|
|
||||||
renders. The coach sits at the **cortex** layer: it proposes at a decision point
|
|
||||||
the state machine exposes (planning), but never forces a transition.
|
|
||||||
|
|
||||||
### The `ai` package — two layers
|
|
||||||
|
|
||||||
The pluggability requirement is met by separating *what we ask* from *how we
|
|
||||||
reach a CLI*.
|
|
||||||
|
|
||||||
**Layer 1 — `Backend` (the pluggable adapter).**
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
|
||||||
// and arguments they run.
|
|
||||||
type Backend interface {
|
|
||||||
// Run sends prompt to the CLI and returns its raw stdout.
|
|
||||||
Run(ctx context.Context, prompt string) (string, error)
|
|
||||||
// Name identifies the backend (e.g. "claude", "codex").
|
|
||||||
Name() string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Two real adapters. The exact invocations below were verified empirically on
|
|
||||||
this machine (claude 2.1.154, codex-cli 0.135.0); both authenticate via the
|
|
||||||
existing CLI login — **no API keys**.
|
|
||||||
|
|
||||||
- **`claudeBackend`** runs:
|
|
||||||
```
|
|
||||||
claude --print --tools "" --no-session-persistence --output-format text
|
|
||||||
```
|
|
||||||
The prompt is delivered on **stdin** (avoids argv limits and shell-escaping;
|
|
||||||
also dodges a quirk where an empty `--tools ""` positional can be mistaken for
|
|
||||||
the prompt). The model's answer is exactly **stdout** (trailing newline
|
|
||||||
trimmed). `--tools ""` disables all tools so it just answers;
|
|
||||||
`--no-session-persistence` avoids writing resumable session files. Do **not**
|
|
||||||
use `--bare` (it forces `ANTHROPIC_API_KEY` and ignores the machine's login).
|
|
||||||
|
|
||||||
- **`codexBackend`** runs:
|
|
||||||
```
|
|
||||||
codex exec --skip-git-repo-check --ignore-user-config --ignore-rules \
|
|
||||||
-s read-only -a never --ephemeral -o <tmpfile> -
|
|
||||||
```
|
|
||||||
The prompt is delivered on **stdin** (the trailing `-` tells codex to read it
|
|
||||||
from stdin). codex's **stdout is not clean** (it includes session preamble),
|
|
||||||
so the adapter writes the final answer to a per-call **temp file** via `-o`,
|
|
||||||
then reads and returns that file's contents. The adapter creates the temp file
|
|
||||||
(`os.CreateTemp`) and removes it on return. The flags matter:
|
|
||||||
`--ignore-user-config --ignore-rules -s read-only` stop codex from executing
|
|
||||||
shell commands driven by local config (observed: it otherwise runs tool calls
|
|
||||||
even for a trivial prompt, adding latency); `-a never` disables approval
|
|
||||||
prompts for headless use; `--ephemeral` skips persisting session files;
|
|
||||||
`--skip-git-repo-check` lets it run anywhere.
|
|
||||||
|
|
||||||
Both use `os/exec` with the `ctx` passed to `exec.CommandContext` so a timeout
|
|
||||||
cancels the child process. Each adapter stores its command name and base args in
|
|
||||||
struct fields so argument construction is unit-testable without spawning a
|
|
||||||
process. The codex adapter's temp-file handling lives inside its `Run` so the
|
|
||||||
`Backend` interface stays uniform (`Run(ctx, prompt) (string, error)`).
|
|
||||||
|
|
||||||
A selector constructs the configured backend:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// NewBackend returns the named backend, or an error for an unknown name.
|
|
||||||
// name "" defaults to "claude".
|
|
||||||
func NewBackend(name string) (Backend, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Layer 2 — `Coach` (backend-agnostic capability).**
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Proposal is the coach's structured suggestion for a commitment. It is NOT a
|
|
||||||
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
|
|
||||||
type Proposal struct {
|
|
||||||
NextAction string
|
|
||||||
SuccessCondition string
|
|
||||||
TimeboxSecs int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coach turns a free-text intent into a validated Proposal.
|
|
||||||
type Coach interface {
|
|
||||||
Coach(ctx context.Context, intent string) (Proposal, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Service` implements `Coach` over any `Backend`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Service struct {
|
|
||||||
backend Backend
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(b Backend) *Service
|
|
||||||
```
|
|
||||||
|
|
||||||
`Coach` builds a strict prompt, calls `backend.Run`, extracts and parses the
|
|
||||||
JSON, and validates it. The `ai` package imports nothing from the rest of the
|
|
||||||
app (it returns its own `Proposal`, not `domain.Commitment`), so it stays a leaf
|
|
||||||
package with no import cycles.
|
|
||||||
|
|
||||||
### Prompt and JSON contract
|
|
||||||
|
|
||||||
The prompt instructs the model to act as a focus coach and to **return only
|
|
||||||
JSON** of the form:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"next_action": "Draft the executive summary section",
|
|
||||||
"success_condition": "Summary section has 3 paragraphs covering revenue, risks, outlook",
|
|
||||||
"timebox_minutes": 25
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Parsing is tolerant of a chatty CLI:
|
|
||||||
|
|
||||||
- `extractJSON(s string) (string, error)` scans for the first balanced `{...}`
|
|
||||||
object in the output and returns it. This survives leading/trailing prose or
|
|
||||||
code fences.
|
|
||||||
- `parseProposal(jsonStr string) (Proposal, error)` unmarshals into an internal
|
|
||||||
struct with `next_action`, `success_condition`, `timebox_minutes`, then:
|
|
||||||
- trims whitespace; errors if `next_action` or `success_condition` is empty;
|
|
||||||
- errors if `timebox_minutes <= 0`;
|
|
||||||
- converts minutes to `TimeboxSecs` (`minutes * 60`).
|
|
||||||
|
|
||||||
All parse/validation failures return a non-nil error; the caller degrades
|
|
||||||
gracefully (see below). Sentinel errors: `ErrEmptyResponse`, `ErrNoJSON`,
|
|
||||||
`ErrInvalidProposal`.
|
|
||||||
|
|
||||||
Both CLIs also offer native structured-output flags (claude
|
|
||||||
`--output-format json --json-schema`; codex `--output-schema <file>`) that would
|
|
||||||
guarantee shape. We deliberately do **not** use them in M2: they diverge the two
|
|
||||||
adapters (different flags, envelope vs file) and would push schema concerns into
|
|
||||||
the `Backend` layer. Prompt-instructed JSON + tolerant `extractJSON` keeps the
|
|
||||||
`Backend` interface uniform and the parsing in one place. Native schemas remain a
|
|
||||||
clean future robustness upgrade behind the same `Coach` boundary.
|
|
||||||
|
|
||||||
### `session.Controller` — async coach orchestration
|
|
||||||
|
|
||||||
A new method drives the coach using the **exact concurrency pattern** already in
|
|
||||||
`RecordWindow`: mutate state under the mutex, then call `notify()` with the
|
|
||||||
mutex released (`session.go:139-146`).
|
|
||||||
|
|
||||||
```go
|
|
||||||
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
|
||||||
// RequestCoach degrade gracefully.
|
|
||||||
func (c *Controller) SetCoach(coach ai.Coach)
|
|
||||||
|
|
||||||
// RequestCoach starts an async coach call for the given intent. It is a no-op
|
|
||||||
// error path (not a hard failure) unless the runtime state is wrong.
|
|
||||||
func (c *Controller) RequestCoach(intent string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior of `RequestCoach`:
|
|
||||||
|
|
||||||
1. Lock. If `runtimeState != RuntimePlanning`, unlock and return
|
|
||||||
`ErrNotPlanning` (a real client error — coaching only makes sense in
|
|
||||||
planning).
|
|
||||||
2. If `coach == nil`: set coach state to `status=error`,
|
|
||||||
`err="coach unavailable"`, unlock, `notify()`, return `nil` (graceful — not
|
|
||||||
an HTTP error).
|
|
||||||
3. Otherwise: increment `coachGen`, capture `gen := coachGen`, set
|
|
||||||
`status=pending`, clear prior proposal/error, capture the `coach` reference,
|
|
||||||
unlock, `notify()` (broadcasts the pending state).
|
|
||||||
4. Launch a goroutine:
|
|
||||||
- `ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)`
|
|
||||||
(`coachTimeout = 60 * time.Second` — codex in particular runs tens of
|
|
||||||
seconds even for trivial prompts; 60s gives a real coaching prompt
|
|
||||||
headroom); `defer cancel()`.
|
|
||||||
- Call `coach.Coach(ctx, intent)`.
|
|
||||||
- Lock. **If `gen != c.coachGen` or `runtimeState != RuntimePlanning`,
|
|
||||||
unlock and return** (stale result — a newer request superseded this one, or
|
|
||||||
the user left planning). Discard silently.
|
|
||||||
- On error: `status=error`, `err=<sanitized message>`, `proposal=nil`.
|
|
||||||
- On success: `status=ready`, `proposal=<the Proposal>`, `err=""`.
|
|
||||||
- Unlock, `notify()`.
|
|
||||||
|
|
||||||
The intent string is **not** stored on the controller; it is captured by the
|
|
||||||
goroutine closure only.
|
|
||||||
|
|
||||||
#### Ephemeral state
|
|
||||||
|
|
||||||
The coach state lives on the controller as plain fields and is **never written
|
|
||||||
to the snapshot**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// on Controller:
|
|
||||||
coach ai.Coach
|
|
||||||
coachStatus string // "idle" | "pending" | "ready" | "error"
|
|
||||||
coachProposal *ai.Proposal
|
|
||||||
coachErr string
|
|
||||||
coachGen int
|
|
||||||
```
|
|
||||||
|
|
||||||
`persistLocked()` is **not** modified — `store.Snapshot` gains no coach fields.
|
|
||||||
Rationale: a proposal is pre-commitment advice; if the daemon restarts during
|
|
||||||
planning, there is nothing to recover, and the user simply re-sharpens.
|
|
||||||
|
|
||||||
Coach state is reset to `idle` (proposal nil, err "") in two places:
|
|
||||||
|
|
||||||
- `EnterPlanning` — entering planning starts with a clean coach.
|
|
||||||
- `StartManualCommitment` and the `enterReview`/`End` paths implicitly leave
|
|
||||||
planning; coach state is reset to `idle` there so a stale `ready` proposal is
|
|
||||||
not projected outside planning. (Concretely: reset in `EnterPlanning` and on
|
|
||||||
any successful leave-planning transition.)
|
|
||||||
|
|
||||||
#### State projection
|
|
||||||
|
|
||||||
`State` gains a coach projection, populated **only while in planning**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ProposalView struct {
|
|
||||||
NextAction string `json:"next_action"`
|
|
||||||
SuccessCondition string `json:"success_condition"`
|
|
||||||
TimeboxSecs int64 `json:"timebox_secs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CoachView struct {
|
|
||||||
Status string `json:"status"` // idle | pending | ready | error
|
|
||||||
Proposal *ProposalView `json:"proposal,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// added to State:
|
|
||||||
// Coach *CoachView `json:"coach,omitempty"`
|
|
||||||
```
|
|
||||||
|
|
||||||
In `stateLocked()`: if `runtimeState == RuntimePlanning`, attach a `CoachView`
|
|
||||||
with the current status (default `idle`), the proposal if `ready`, and the error
|
|
||||||
if `error`. Outside planning, `Coach` is `nil` and omitted.
|
|
||||||
|
|
||||||
### `web` layer
|
|
||||||
|
|
||||||
One new route:
|
|
||||||
|
|
||||||
```go
|
|
||||||
r.POST("/coach", s.handleCoach)
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
type coachRequest struct {
|
|
||||||
Intent string `json:"intent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleCoach(c *gin.Context) {
|
|
||||||
var req coachRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.respond(c, s.ctrl.RequestCoach(req.Intent))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`respond` already broadcasts on success and maps errors. `ErrNotPlanning` is a
|
|
||||||
plain (non-`IllegalTransitionError`) error, so it maps to
|
|
||||||
`http.StatusBadRequest` — acceptable, since the UI only shows Sharpen during
|
|
||||||
planning. The pending → ready/error progression reaches the browser entirely
|
|
||||||
over the existing SSE stream; the POST response itself is not relied upon for
|
|
||||||
the proposal.
|
|
||||||
|
|
||||||
### UI (`internal/web/static/index.html`)
|
|
||||||
|
|
||||||
The Planning view gains an intent box and a Sharpen button **above** the three
|
|
||||||
existing inputs:
|
|
||||||
|
|
||||||
```
|
|
||||||
[ Rough intent .......................... ] [ Sharpen ]
|
|
||||||
(coach status line: thinking… / error note)
|
|
||||||
Next action [ ........................ ]
|
|
||||||
Success condition[ ........................ ]
|
|
||||||
Minutes [ 25 ]
|
|
||||||
[ Start commitment ]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Partial-update requirement.** Today `render()` replaces the planning view's
|
|
||||||
`innerHTML` on every SSE message. With a coach, SSE messages now arrive *while
|
|
||||||
the user is typing*, so a full rebuild would wipe their input and focus. The
|
|
||||||
fix:
|
|
||||||
|
|
||||||
- Track the currently rendered runtime state in a module variable
|
|
||||||
(e.g. `renderedState`).
|
|
||||||
- When an SSE message arrives and `rs === 'planning'` **and** the planning view
|
|
||||||
is already mounted, do **not** rebuild. Instead call an
|
|
||||||
`updatePlanningCoach(state.coach)` that only:
|
|
||||||
- updates the coach status line (pending → "thinking…", error → the message,
|
|
||||||
idle/absent → empty);
|
|
||||||
- when status is `ready` and the proposal has not yet been applied for this
|
|
||||||
generation, writes `proposal.next_action`, `proposal.success_condition`, and
|
|
||||||
`Math.round(proposal.timebox_secs / 60)` into the three inputs, then runs the
|
|
||||||
existing `check()` to enable Start. Pre-fill happens once per ready proposal
|
|
||||||
(guard with a flag) so it does not clobber subsequent manual edits on every
|
|
||||||
SSE tick.
|
|
||||||
- Only rebuild the planning structure when transitioning *into* planning from a
|
|
||||||
different state.
|
|
||||||
|
|
||||||
The Sharpen button POSTs `{ intent }` to `/coach` and shows the pending state
|
|
||||||
optimistically; the disabled/enabled logic for Start is unchanged. Other runtime
|
|
||||||
states (`locked`/`active`/`review`) keep their current full-rebuild render.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Backend selection is config-driven from day one:
|
|
||||||
|
|
||||||
- Env var `ANTIDRIFT_AI_BACKEND` selects the adapter: `claude` (default) or
|
|
||||||
`codex`. Unknown values are a startup error.
|
|
||||||
- `cmd/antidriftd/main.go` reads the env var, calls `ai.NewBackend(name)`, wraps
|
|
||||||
it in `ai.NewService(backend)`, and calls `ctrl.SetCoach(service)`. If
|
|
||||||
`NewBackend` errors, the daemon logs a warning and runs **without** a coach
|
|
||||||
(manual planning still works) rather than failing to start — graceful
|
|
||||||
degradation extends to misconfiguration.
|
|
||||||
|
|
||||||
## Error Handling and Degradation
|
|
||||||
|
|
||||||
Every failure surfaces as a non-blocking `status=error` in the coach view, never
|
|
||||||
as a broken Planning view:
|
|
||||||
|
|
||||||
| Failure | Result |
|
|
||||||
| ------- | ------ |
|
|
||||||
| No backend wired (`SetCoach` never called / nil) | `RequestCoach` sets `status=error`, "coach unavailable"; returns nil |
|
|
||||||
| CLI binary missing | `backend.Run` errors → goroutine sets `status=error` |
|
|
||||||
| CLI timeout (>60s) | `context` cancels child → error → `status=error` |
|
|
||||||
| Empty / non-JSON output | `extractJSON`/`parseProposal` error → `status=error` |
|
|
||||||
| Missing/empty fields, non-positive timebox | `parseProposal` error → `status=error` |
|
|
||||||
| Request issued outside planning | `RequestCoach` returns `ErrNotPlanning` → HTTP 400 |
|
|
||||||
|
|
||||||
Error messages shown to the UI are sanitized to a short human string; raw CLI
|
|
||||||
stderr is logged server-side, not surfaced to the browser.
|
|
||||||
|
|
||||||
## Package Layout Changes
|
|
||||||
|
|
||||||
| Package | Change |
|
|
||||||
| ------- | ------ |
|
|
||||||
| `ai` (new) | `Backend` interface; `claudeBackend`, `codexBackend`; `NewBackend`; `Coach` interface; `Proposal`; `Service`; prompt builder; `extractJSON`; `parseProposal`; sentinel errors; `fakeBackend` (test) |
|
|
||||||
| `session` | `coach` fields; `SetCoach`; `RequestCoach`; coach reset in `EnterPlanning` and leave-planning paths; `CoachView`/`ProposalView`; `Coach` field on `State`; `stateLocked` projection |
|
|
||||||
| `web` | `POST /coach` route + `handleCoach` + `coachRequest` |
|
|
||||||
| `web/static/index.html` | intent box + Sharpen button; `updatePlanningCoach`; partial-update guard in `render()` |
|
|
||||||
| `cmd/antidriftd` | read `ANTIDRIFT_AI_BACKEND`; build backend + service; `ctrl.SetCoach`; graceful fallback |
|
|
||||||
|
|
||||||
`ai` stays small and single-purpose, consistent with the token-efficiency design
|
|
||||||
constraint.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
**`ai` package:**
|
|
||||||
|
|
||||||
- `extractJSON`: bare object, object wrapped in prose, fenced code block, no JSON
|
|
||||||
(error), multiple objects (returns first balanced one).
|
|
||||||
- `parseProposal`: valid; missing `next_action`; empty `success_condition`;
|
|
||||||
`timebox_minutes` of 0 and negative; minutes→secs conversion.
|
|
||||||
- `Service.Coach` against a `fakeBackend` returning canned strings: success,
|
|
||||||
chatty-wrapped success, malformed → error.
|
|
||||||
- `claudeBackend`/`codexBackend`: argument construction is correct and the prompt
|
|
||||||
is routed to stdin (assert on the built `*exec.Cmd` `Args`/`Stdin` fields; do
|
|
||||||
not spawn the real CLI). For codex, assert the `-o <tmpfile>` flag is present
|
|
||||||
and that `Run` would read that path (factor the temp-file path out so it is
|
|
||||||
injectable/observable in the test).
|
|
||||||
- `NewBackend`: returns claude by default, codex by name, error on unknown.
|
|
||||||
|
|
||||||
**`session` package** (with a fake `ai.Coach`):
|
|
||||||
|
|
||||||
- `RequestCoach` in planning, fake returns a proposal: status goes
|
|
||||||
`pending` then `ready`; `State().Coach.Proposal` matches; `onChange` fires
|
|
||||||
twice.
|
|
||||||
- Fake returns an error: status goes `pending` then `error`.
|
|
||||||
- Nil coach: status `error` "coach unavailable"; `RequestCoach` returns nil.
|
|
||||||
- Wrong state (locked/active): `RequestCoach` returns `ErrNotPlanning`; no
|
|
||||||
goroutine, no state change.
|
|
||||||
- Stale generation: two `RequestCoach` calls; the first (slow) fake result is
|
|
||||||
discarded, only the second is projected. (Drive via a fake whose return is
|
|
||||||
gated on a channel so ordering is deterministic.)
|
|
||||||
- Leaving planning discards a pending/ready proposal: `Coach` is nil in `State`
|
|
||||||
once active.
|
|
||||||
- Snapshot has no coach fields (round-trip a snapshot, assert unaffected).
|
|
||||||
|
|
||||||
**`web` package** (with a fake `ai.Coach` wired into a real controller):
|
|
||||||
|
|
||||||
- `POST /coach` in planning returns 200 and the broadcast state shows
|
|
||||||
`status=pending` (or `ready` if the fake is synchronous).
|
|
||||||
- `POST /coach` outside planning returns 400.
|
|
||||||
- `POST /coach` with invalid JSON returns 400.
|
|
||||||
- Coach-unavailable controller: `POST /coach` returns 200, state shows
|
|
||||||
`status=error`.
|
|
||||||
|
|
||||||
All tests use fakes; **no test invokes the real `claude`/`codex` CLI**. Tests
|
|
||||||
must remain race-clean (`go test -race ./...`), consistent with M1.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- `ai` package with both adapters, `Coach`/`Service`, parsing, and tests.
|
|
||||||
- `RequestCoach` async flow with generation-guard and graceful degradation.
|
|
||||||
- `/coach` route and Planning-view Sharpen flow that pre-fills without clobbering
|
|
||||||
user input.
|
|
||||||
- `ANTIDRIFT_AI_BACKEND` wiring in the daemon with graceful fallback.
|
|
||||||
- `go test -race ./...` passes; manual smoke: type an intent, Sharpen, see the
|
|
||||||
three fields populate, edit, Start.
|
|
||||||
- README/roadmap note that M2 is complete (consistent with prior milestones).
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-8
@@ -2,9 +2,10 @@ package ai
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
// Coach turns a free-text intent into a validated Proposal.
|
// Coach turns a free-text intent into a validated Proposal. grounding is
|
||||||
|
// optional standing context about the user (the knowledge port); "" means none.
|
||||||
type Coach interface {
|
type Coach interface {
|
||||||
Coach(ctx context.Context, intent string) (Proposal, error)
|
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
||||||
@@ -21,24 +22,59 @@ type Service struct {
|
|||||||
|
|
||||||
func NewService(b Backend) *Service { return &Service{backend: b} }
|
func NewService(b Backend) *Service { return &Service{backend: b} }
|
||||||
|
|
||||||
func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) {
|
func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) {
|
||||||
out, err := s.backend.Run(ctx, buildPrompt(intent))
|
out, err := s.backend.Run(ctx, buildPrompt(intent, grounding))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Proposal{}, err
|
return Proposal{}, err
|
||||||
}
|
}
|
||||||
return parseProposal(out)
|
return parseProposal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPrompt(intent string) string {
|
func buildPrompt(intent, grounding string) string {
|
||||||
return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
preamble := `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
||||||
|
|
||||||
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}
|
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- next_action: a single concrete imperative action, doable now.
|
- next_action: a single concrete imperative action, doable now.
|
||||||
- success_condition: observable and verifiable; how you'd know it is done.
|
- success_condition: observable and verifiable; how you'd know it is done.
|
||||||
- timebox_minutes: a realistic integer number of minutes for the action.
|
- timebox_minutes: a realistic integer number of minutes for the action.
|
||||||
|
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.`
|
||||||
|
|
||||||
User intent: ` + intent
|
about := ""
|
||||||
|
if grounding != "" {
|
||||||
|
about = "\n\n## About the user\n" + grounding + "\nUse this standing context to make the commitment fit who they are and what matters to them."
|
||||||
|
}
|
||||||
|
return preamble + about + "\n\nUser intent: " + intent
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriftJudge decides whether the current window is on-task for a commitment.
|
||||||
|
// It takes primitives, not domain/evidence types, so ai stays a leaf package.
|
||||||
|
type DriftJudge interface {
|
||||||
|
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JudgeDrift makes Service satisfy DriftJudge over the same backend as Coach.
|
||||||
|
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildDriftPrompt(commitment, windowClass, windowTitle))
|
||||||
|
if err != nil {
|
||||||
|
return Verdict{}, err
|
||||||
|
}
|
||||||
|
return parseVerdict(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDriftPrompt(commitment, class, title string) string {
|
||||||
|
return `You are a focus monitor. The user committed to a task. Decide whether their CURRENT window is on-task or a distraction.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"on_task": <true or false>, "reason": "<short explanation, one sentence>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- on_task: true if the window plausibly serves the commitment, false if it is a distraction.
|
||||||
|
- reason: one short sentence. REQUIRED when on_task is false.
|
||||||
|
|
||||||
|
Commitment: ` + commitment + `
|
||||||
|
Current window class: ` + class + `
|
||||||
|
Current window title: ` + title
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (f *fakeBackend) Name() string { return "fake" }
|
|||||||
func TestServiceCoachSuccess(t *testing.T) {
|
func TestServiceCoachSuccess(t *testing.T) {
|
||||||
fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`}
|
fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`}
|
||||||
svc := NewService(fb)
|
svc := NewService(fb)
|
||||||
p, err := svc.Coach(context.Background(), "write the tests")
|
p, err := svc.Coach(context.Background(), "write the tests", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("coach: %v", err)
|
t.Fatalf("coach: %v", err)
|
||||||
}
|
}
|
||||||
@@ -36,14 +36,34 @@ func TestServiceCoachSuccess(t *testing.T) {
|
|||||||
|
|
||||||
func TestServiceCoachBackendError(t *testing.T) {
|
func TestServiceCoachBackendError(t *testing.T) {
|
||||||
fb := &fakeBackend{err: errors.New("boom")}
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil {
|
if _, err := NewService(fb).Coach(context.Background(), "x", ""); err == nil {
|
||||||
t.Fatal("want backend error")
|
t.Fatal("want backend error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceCoachUnparseable(t *testing.T) {
|
func TestServiceCoachUnparseable(t *testing.T) {
|
||||||
fb := &fakeBackend{out: "I cannot help with that."}
|
fb := &fakeBackend{out: "I cannot help with that."}
|
||||||
if _, err := NewService(fb).Coach(context.Background(), "x"); !errors.Is(err, ErrNoJSON) {
|
if _, err := NewService(fb).Coach(context.Background(), "x", ""); !errors.Is(err, ErrNoJSON) {
|
||||||
t.Fatalf("want ErrNoJSON, got %v", err)
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoachPromptIncludesGrounding(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
|
||||||
|
if _, err := NewService(fb).Coach(context.Background(), "ship it", "I value small diffs."); err != nil {
|
||||||
|
t.Fatalf("coach: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "About the user") || !strings.Contains(fb.gotPrompt, "small diffs") {
|
||||||
|
t.Fatalf("prompt missing grounding block: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoachEmptyGroundingUnchanged(t *testing.T) {
|
||||||
|
withFb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
|
||||||
|
_, _ = NewService(withFb).Coach(context.Background(), "ship it", "")
|
||||||
|
// The empty-grounding prompt must equal buildPrompt(intent) of the old shape:
|
||||||
|
// it must NOT contain the grounding header.
|
||||||
|
if strings.Contains(withFb.gotPrompt, "About the user") {
|
||||||
|
t.Fatalf("empty grounding leaked a header: %s", withFb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nudger judges whether recent activity within an allowed app still serves the
|
||||||
|
// commitment. Like Coach and DriftJudge it takes primitives, not domain/evidence
|
||||||
|
// types, so ai stays a leaf package. The returned string is a one-sentence
|
||||||
|
// advisory, or "" when the trajectory is still on-task.
|
||||||
|
type Nudger interface {
|
||||||
|
Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidNudge marks a parseable-but-unusable nudge response.
|
||||||
|
var ErrInvalidNudge = errors.New("ai: invalid nudge")
|
||||||
|
|
||||||
|
// Nudge makes Service satisfy Nudger over the same backend as Coach and JudgeDrift.
|
||||||
|
func (s *Service) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildNudgePrompt(commitment, recentTitles))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parseNudge(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNudgePrompt(commitment string, recentTitles []string) string {
|
||||||
|
return `You are a focus monitor. The user committed to a task and is working in an allowed application, so the application itself is fine. Judge whether the SEQUENCE of recent window titles shows them still working toward the commitment, or whether they have drifted onto unrelated work within that app.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"on_track": <true or false>, "message": "<short explanation, one sentence>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- on_track: true if the recent titles plausibly serve the commitment, false if they show unrelated work.
|
||||||
|
- message: one short sentence naming the drift. REQUIRED when on_track is false.
|
||||||
|
|
||||||
|
Commitment: ` + commitment + `
|
||||||
|
Recent window titles (oldest to newest):
|
||||||
|
` + strings.Join(recentTitles, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawNudge struct {
|
||||||
|
OnTrack bool `json:"on_track"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNudge extracts the advisory from raw CLI output. It reuses extractJSON
|
||||||
|
// and the shared empty/no-JSON sentinels. An on-track result yields "". A
|
||||||
|
// concern with no message degrades to "" (silence) rather than an error: the
|
||||||
|
// nudge is ambient, so the safe degenerate is to say nothing, unlike the
|
||||||
|
// interruptive drift verdict which rejects a reasonless drift.
|
||||||
|
func parseNudge(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", ErrInvalidNudge, err)
|
||||||
|
}
|
||||||
|
var raw rawNudge
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
|
||||||
|
}
|
||||||
|
if raw.OnTrack {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.Message), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceNudgeSuccess(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"on_track": false, "message": "reading news, not the auth flow"}`}
|
||||||
|
advisory, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
if advisory == "" {
|
||||||
|
t.Fatal("expected non-empty advisory")
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "fix auth") {
|
||||||
|
t.Fatalf("prompt should embed the commitment, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceNudgeBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"}); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNudge(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"on track yields empty", `{"on_track": true, "message": ""}`, "", nil},
|
||||||
|
{"concern returns message", `{"on_track": false, "message": "editing unrelated CSS, not the auth flow"}`, "editing unrelated CSS, not the auth flow", nil},
|
||||||
|
{"concern is trimmed", `{"on_track": false, "message": " drifted "}`, "drifted", nil},
|
||||||
|
{"concern without message degrades to silence", `{"on_track": false, "message": ""}`, "", nil},
|
||||||
|
{"json embedded in prose", `sure thing: {"on_track": false, "message": "wrong project"} done`, "wrong project", nil},
|
||||||
|
{"empty response", " ", "", ErrEmptyResponse},
|
||||||
|
{"no json", "no braces here", "", ErrNoJSON},
|
||||||
|
{"malformed json", `{"on_track": false, "message":`, "", ErrInvalidNudge},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseNudge(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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-7
@@ -13,9 +13,10 @@ import (
|
|||||||
// Proposal is the coach's structured suggestion for a commitment. It is not a
|
// Proposal is the coach's structured suggestion for a commitment. It is not a
|
||||||
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
|
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
|
||||||
type Proposal struct {
|
type Proposal struct {
|
||||||
NextAction string
|
NextAction string
|
||||||
SuccessCondition string
|
SuccessCondition string
|
||||||
TimeboxSecs int64
|
TimeboxSecs int64
|
||||||
|
AllowedWindowClasses []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -61,9 +62,10 @@ func extractJSON(s string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type rawProposal struct {
|
type rawProposal struct {
|
||||||
NextAction string `json:"next_action"`
|
NextAction string `json:"next_action"`
|
||||||
SuccessCondition string `json:"success_condition"`
|
SuccessCondition string `json:"success_condition"`
|
||||||
TimeboxMinutes int64 `json:"timebox_minutes"`
|
TimeboxMinutes int64 `json:"timebox_minutes"`
|
||||||
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseProposal extracts and validates a Proposal from raw CLI output.
|
// parseProposal extracts and validates a Proposal from raw CLI output.
|
||||||
@@ -91,5 +93,16 @@ func parseProposal(s string) (Proposal, error) {
|
|||||||
if na == "" || sc == "" || raw.TimeboxMinutes <= 0 {
|
if na == "" || sc == "" || raw.TimeboxMinutes <= 0 {
|
||||||
return Proposal{}, ErrInvalidProposal
|
return Proposal{}, ErrInvalidProposal
|
||||||
}
|
}
|
||||||
return Proposal{NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60}, nil
|
classes := make([]string, 0, len(raw.AllowedWindowClasses))
|
||||||
|
for _, cls := range raw.AllowedWindowClasses {
|
||||||
|
if t := strings.TrimSpace(cls); t != "" {
|
||||||
|
classes = append(classes, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Proposal{
|
||||||
|
NextAction: na,
|
||||||
|
SuccessCondition: sc,
|
||||||
|
TimeboxSecs: raw.TimeboxMinutes * 60,
|
||||||
|
AllowedWindowClasses: classes,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,3 +69,23 @@ func TestParseProposal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseProposalReadsAllowedClasses(t *testing.T) {
|
||||||
|
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20,"allowed_window_classes":["code"," firefox ",""]}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.AllowedWindowClasses) != 2 || p.AllowedWindowClasses[0] != "code" || p.AllowedWindowClasses[1] != "firefox" {
|
||||||
|
t.Fatalf("classes wrong (blanks should drop, trim applied): %#v", p.AllowedWindowClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProposalAllowedClassesOptional(t *testing.T) {
|
||||||
|
p, err := parseProposal(`{"next_action":"a","success_condition":"b","timebox_minutes":20}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.AllowedWindowClasses) != 0 {
|
||||||
|
t.Fatalf("absent field should yield empty slice, got %#v", p.AllowedWindowClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,84 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reflection is the reviewer's output: two short, single-line fields.
|
||||||
|
type Reflection struct {
|
||||||
|
Recap string // backward-looking: how the session went (shown on Review)
|
||||||
|
CarryForward string // forward-looking: one takeaway for the next session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviewer reflects on a just-finished session, read against recent history. It
|
||||||
|
// takes primitives, not domain/store types, so ai stays a leaf package.
|
||||||
|
//
|
||||||
|
// finished: a compact description of the session that just ended.
|
||||||
|
// history: a compact description of the last few prior sessions ("" if none).
|
||||||
|
type Reviewer interface {
|
||||||
|
Review(ctx context.Context, finished, history string) (Reflection, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidReflection marks output that parsed as JSON but lacked a usable
|
||||||
|
// recap, so callers can distinguish "no attempt" from "bad attempt".
|
||||||
|
var ErrInvalidReflection = errors.New("ai: invalid reflection")
|
||||||
|
|
||||||
|
// Review makes Service satisfy Reviewer over the same backend as Coach.
|
||||||
|
func (s *Service) Review(ctx context.Context, finished, history string) (Reflection, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildReviewPrompt(finished, history))
|
||||||
|
if err != nil {
|
||||||
|
return Reflection{}, err
|
||||||
|
}
|
||||||
|
return parseReflection(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReviewPrompt(finished, history string) string {
|
||||||
|
preamble := `You are a focus reviewer. A work session just ended. Reflect on it in two short lines.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"recap": "<one sentence: how this session went>", "carry_forward": "<one sentence: the single most useful thing to do differently next session>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- recap: one short sentence, backward-looking, grounded in the session below.
|
||||||
|
- carry_forward: one short, actionable sentence for the next session. It may reference patterns across the recent sessions if any are given.
|
||||||
|
- Keep each field to a single short sentence.`
|
||||||
|
|
||||||
|
hist := ""
|
||||||
|
if strings.TrimSpace(history) != "" {
|
||||||
|
hist = "\n\n## Recent sessions (oldest first)\n" + history
|
||||||
|
}
|
||||||
|
return preamble + "\n\n## Session that just ended\n" + finished + hist
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawReflection struct {
|
||||||
|
Recap string `json:"recap"`
|
||||||
|
CarryForward string `json:"carry_forward"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReflection extracts a Reflection from raw CLI output. A blank recap is
|
||||||
|
// rejected (ErrInvalidReflection); an empty carry_forward is allowed.
|
||||||
|
func parseReflection(s string) (Reflection, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return Reflection{}, ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return Reflection{}, ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
|
||||||
|
}
|
||||||
|
var raw rawReflection
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
|
||||||
|
}
|
||||||
|
recap := strings.TrimSpace(raw.Recap)
|
||||||
|
if recap == "" {
|
||||||
|
return Reflection{}, ErrInvalidReflection
|
||||||
|
}
|
||||||
|
return Reflection{Recap: recap, CarryForward: strings.TrimSpace(raw.CarryForward)}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReviewPromptIncludesSessionAndHistory(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
|
||||||
|
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", "HISTORY-BLOCK"); err != nil {
|
||||||
|
t.Fatalf("review: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "FINISHED-BLOCK") {
|
||||||
|
t.Fatalf("prompt missing finished session: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "HISTORY-BLOCK") || !strings.Contains(fb.gotPrompt, "Recent sessions") {
|
||||||
|
t.Fatalf("prompt missing history block: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewPromptOmitsEmptyHistory(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
|
||||||
|
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", ""); err != nil {
|
||||||
|
t.Fatalf("review: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(fb.gotPrompt, "Recent sessions") {
|
||||||
|
t.Fatalf("empty history must not add a history header: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewServiceParsesReflection(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `sure: {"recap":"held focus on the port","carry_forward":"start in the editor next time"}`}
|
||||||
|
refl, err := NewService(fb).Review(context.Background(), "f", "h")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("review: %v", err)
|
||||||
|
}
|
||||||
|
if refl.Recap != "held focus on the port" || refl.CarryForward != "start in the editor next time" {
|
||||||
|
t.Fatalf("bad reflection: %+v", refl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewServiceBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).Review(context.Background(), "f", "h"); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionEmpty(t *testing.T) {
|
||||||
|
if _, err := parseReflection(""); !errors.Is(err, ErrEmptyResponse) {
|
||||||
|
t.Fatalf("want ErrEmptyResponse, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionNoJSON(t *testing.T) {
|
||||||
|
if _, err := parseReflection("I cannot help."); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionRequiresRecap(t *testing.T) {
|
||||||
|
if _, err := parseReflection(`{"recap":" ","carry_forward":"x"}`); !errors.Is(err, ErrInvalidReflection) {
|
||||||
|
t.Fatalf("blank recap should be invalid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReflectionAllowsEmptyCarryForward(t *testing.T) {
|
||||||
|
refl, err := parseReflection(`{"recap":"did the thing","carry_forward":""}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("carry_forward may be empty: %v", err)
|
||||||
|
}
|
||||||
|
if refl.Recap != "did the thing" || refl.CarryForward != "" {
|
||||||
|
t.Fatalf("bad reflection: %+v", refl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verdict is the drift judge's call on a single window.
|
||||||
|
type Verdict struct {
|
||||||
|
OnTask bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidVerdict marks a parseable-but-unusable judge response.
|
||||||
|
var ErrInvalidVerdict = errors.New("ai: invalid verdict")
|
||||||
|
|
||||||
|
type rawVerdict struct {
|
||||||
|
OnTask bool `json:"on_task"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVerdict extracts and validates a Verdict from raw CLI output. It reuses
|
||||||
|
// extractJSON and the shared empty/no-JSON sentinels. A drifting verdict with no
|
||||||
|
// reason is unusable for the UI and is rejected as ErrInvalidVerdict.
|
||||||
|
func parseVerdict(s string) (Verdict, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return Verdict{}, ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return Verdict{}, ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err)
|
||||||
|
}
|
||||||
|
var raw rawVerdict
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return Verdict{}, fmt.Errorf("%w: %v", ErrInvalidVerdict, err)
|
||||||
|
}
|
||||||
|
v := Verdict{OnTask: raw.OnTask, Reason: strings.TrimSpace(raw.Reason)}
|
||||||
|
if !v.OnTask && v.Reason == "" {
|
||||||
|
return Verdict{}, ErrInvalidVerdict
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseVerdictOnTask(t *testing.T) {
|
||||||
|
v, err := parseVerdict(`{"on_task": true, "reason": ""}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !v.OnTask {
|
||||||
|
t.Fatalf("expected on-task, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictDriftingChatty(t *testing.T) {
|
||||||
|
v, err := parseVerdict("Sure! here is the call:\n{\"on_task\": false, \"reason\": \"Reddit is unrelated\"}\nhope that helps")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if v.OnTask || v.Reason != "Reddit is unrelated" {
|
||||||
|
t.Fatalf("bad verdict: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictDriftingNeedsReason(t *testing.T) {
|
||||||
|
if _, err := parseVerdict(`{"on_task": false}`); !errors.Is(err, ErrInvalidVerdict) {
|
||||||
|
t.Fatalf("want ErrInvalidVerdict for reasonless drift, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictEmpty(t *testing.T) {
|
||||||
|
if _, err := parseVerdict(" "); !errors.Is(err, ErrEmptyResponse) {
|
||||||
|
t.Fatalf("want ErrEmptyResponse, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictNoJSON(t *testing.T) {
|
||||||
|
if _, err := parseVerdict("I cannot help"); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceJudgeDrift(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"on_task": false, "reason": "YouTube is off-task"}`}
|
||||||
|
v, err := NewService(fb).JudgeDrift(context.Background(), "write the report", "firefox", "YouTube")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("judge: %v", err)
|
||||||
|
}
|
||||||
|
if v.OnTask || v.Reason == "" {
|
||||||
|
t.Fatalf("bad verdict: %+v", v)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "write the report") || !strings.Contains(fb.gotPrompt, "YouTube") {
|
||||||
|
t.Fatalf("prompt should embed commitment and window: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceJudgeDriftBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).JudgeDrift(context.Background(), "x", "c", "t"); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Package enforce makes drift cost something at the OS level. Tier A minimizes
|
||||||
|
// the active window when the session is enforcing and the drift judge has
|
||||||
|
// confirmed the window is off-task. The Guard is a pure OS primitive; all
|
||||||
|
// policy — whether and when to enforce — lives in the session controller.
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Guard performs OS-level enforcement actions on demand.
|
||||||
|
type Guard interface {
|
||||||
|
// MinimizeActive minimizes the currently-focused window. It is idempotent
|
||||||
|
// (minimizing an already-minimized window is harmless) and best-effort: it
|
||||||
|
// returns an error for diagnostics, but callers never block on it and treat
|
||||||
|
// failure as "enforcement did nothing this time."
|
||||||
|
MinimizeActive(ctx context.Context) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package enforce
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// NewGuard must return a usable Guard on every platform (real on linux, no-op
|
||||||
|
// elsewhere). We assert non-nil only: calling MinimizeActive here would touch a
|
||||||
|
// real X server on linux, which the integration test covers under a DISPLAY
|
||||||
|
// guard. The behavioural contract is exercised in the session package via a fake
|
||||||
|
// Guard.
|
||||||
|
func TestNewGuardReturnsUsableGuard(t *testing.T) {
|
||||||
|
if g := NewGuard(); g == nil {
|
||||||
|
t.Fatal("NewGuard returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !linux && !windows
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// NewGuard returns a no-op guard on platforms without the X11 adapter.
|
||||||
|
func NewGuard() Guard { return noopGuard{} }
|
||||||
|
|
||||||
|
type noopGuard struct{}
|
||||||
|
|
||||||
|
func (noopGuard) MinimizeActive(context.Context) error { return nil }
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jezek/xgbutil"
|
||||||
|
"github.com/jezek/xgbutil/ewmh"
|
||||||
|
"github.com/jezek/xgbutil/icccm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewGuard returns the real X11 window-minimize guard.
|
||||||
|
func NewGuard() Guard { return x11Guard{} }
|
||||||
|
|
||||||
|
type x11Guard struct{}
|
||||||
|
|
||||||
|
// MinimizeActive iconifies the currently-focused window by sending an ICCCM
|
||||||
|
// WM_CHANGE_STATE -> IconicState client message to the root window. It opens a
|
||||||
|
// short-lived X connection per call: enforcement fires at most once per drift
|
||||||
|
// observation (which is debounce-gated upstream), so there is no shared
|
||||||
|
// connection or event loop to manage, and nothing to race on shutdown. Any X
|
||||||
|
// failure is returned for the caller to log; the caller never blocks on it.
|
||||||
|
func (x11Guard) MinimizeActive(_ context.Context) error {
|
||||||
|
X, err := xgbutil.NewConn()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enforce: cannot connect to X server: %w", err)
|
||||||
|
}
|
||||||
|
defer X.Conn().Close()
|
||||||
|
|
||||||
|
active, err := ewmh.ActiveWindowGet(X)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enforce: no active window: %w", err)
|
||||||
|
}
|
||||||
|
if active == 0 {
|
||||||
|
return nil // nothing focused; nothing to minimize
|
||||||
|
}
|
||||||
|
if err := ewmh.ClientEvent(X, active, "WM_CHANGE_STATE", icccm.StateIconic); err != nil {
|
||||||
|
return fmt.Errorf("enforce: minimize request failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestX11GuardMinimizeActiveDoesNotPanic(t *testing.T) {
|
||||||
|
if os.Getenv("DISPLAY") == "" {
|
||||||
|
t.Skip("no DISPLAY; skipping live X11 minimize smoke test")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// Either it minimizes the active window or returns an error (e.g. no active
|
||||||
|
// window); we only assert it returns without panicking.
|
||||||
|
if err := NewGuard().MinimizeActive(ctx); err != nil {
|
||||||
|
t.Logf("MinimizeActive returned (acceptable): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
||||||
|
// M3 matches on window class and title only; the domain and command helpers are
|
||||||
|
// ported for completeness and tests but have no live data source yet.
|
||||||
|
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool {
|
||||||
|
return windowClassAllowed(ctx, class) || windowTitleAllowed(ctx, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = normalizeCasefolded(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.WindowClasses {
|
||||||
|
// 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 false
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowTitleAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = normalizeCasefolded(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.WindowTitleSubstrings {
|
||||||
|
a := normalizeCasefolded(allowed)
|
||||||
|
if a != "" && strings.Contains(candidate, a) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = normalizeDomain(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.Domains {
|
||||||
|
a := normalizeDomain(allowed)
|
||||||
|
if a == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if candidate == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(candidate, a) && strings.HasSuffix(candidate[:len(candidate)-len(a)], ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandAllowed(ctx domain.AllowedContext, candidate string) bool {
|
||||||
|
candidate = executableBasename(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range ctx.Commands {
|
||||||
|
if executableBasename(allowed) == candidate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDomain(v string) string {
|
||||||
|
return strings.ToLower(strings.TrimRight(strings.TrimSpace(v), "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCasefolded(v string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// executableBasename returns the lowercased basename of the first whitespace-
|
||||||
|
// separated token (the executable), splitting on both / and \.
|
||||||
|
func executableBasename(command string) string {
|
||||||
|
fields := strings.Fields(command)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
exe := fields[0]
|
||||||
|
if i := strings.LastIndexAny(exe, `/\`); i >= 0 {
|
||||||
|
exe = exe[i+1:]
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(exe))
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{WindowClasses: []string{" code "}}
|
||||||
|
if !windowClassAllowed(ctx, " Code ") {
|
||||||
|
t.Fatal("class should match case-insensitively after trim")
|
||||||
|
}
|
||||||
|
if windowClassAllowed(ctx, "firefox") {
|
||||||
|
t.Fatal("unrelated class must not match")
|
||||||
|
}
|
||||||
|
if windowClassAllowed(ctx, " ") {
|
||||||
|
t.Fatal("empty candidate must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
|
||||||
|
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
|
||||||
|
t.Fatal("title substring should match after trim/casefold")
|
||||||
|
}
|
||||||
|
if windowTitleAllowed(ctx, "random video") {
|
||||||
|
t.Fatal("unrelated title must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainMatchesExactAndSubdomain(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{Domains: []string{" GitHub.COM. "}}
|
||||||
|
if !domainAllowed(ctx, "github.com") {
|
||||||
|
t.Fatal("exact domain should match")
|
||||||
|
}
|
||||||
|
if !domainAllowed(ctx, " DOCS.GITHUB.COM. ") {
|
||||||
|
t.Fatal("subdomain should match after normalization")
|
||||||
|
}
|
||||||
|
if domainAllowed(ctx, "evilgithub.com") {
|
||||||
|
t.Fatal("suffix-without-dot must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandMatchesExecutableBasename(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{Commands: []string{"cargo"}}
|
||||||
|
if !commandAllowed(ctx, "/home/felixm/.cargo/bin/cargo test") {
|
||||||
|
t.Fatal("basename of full path should match")
|
||||||
|
}
|
||||||
|
if !commandAllowed(ctx, "cargo") {
|
||||||
|
t.Fatal("bare command should match")
|
||||||
|
}
|
||||||
|
if commandAllowed(ctx, "/usr/bin/git status") {
|
||||||
|
t.Fatal("unrelated command must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesAllowedClassOrTitle(t *testing.T) {
|
||||||
|
ctx := domain.AllowedContext{
|
||||||
|
WindowClasses: []string{"code"},
|
||||||
|
WindowTitleSubstrings: []string{"antidrift"},
|
||||||
|
}
|
||||||
|
if !MatchesAllowed(ctx, "Code", "anything") {
|
||||||
|
t.Fatal("class match should satisfy MatchesAllowed")
|
||||||
|
}
|
||||||
|
if !MatchesAllowed(ctx, "firefox", "my AntiDrift tab") {
|
||||||
|
t.Fatal("title match should satisfy MatchesAllowed")
|
||||||
|
}
|
||||||
|
if MatchesAllowed(ctx, "firefox", "youtube") {
|
||||||
|
t.Fatal("neither match must be off-task")
|
||||||
|
}
|
||||||
|
if MatchesAllowed(domain.AllowedContext{}, "code", "antidrift") {
|
||||||
|
t.Fatal("empty context matches nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ package evidence
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EvidenceHealth records whether the sensor could observe the active window.
|
// EvidenceHealth records whether the sensor could observe the active window.
|
||||||
@@ -22,18 +24,35 @@ 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+)+%?`)
|
||||||
|
|
||||||
// ScrubTitle removes volatile numeric runs so window titles bucket stably.
|
// extraSpace collapses the whitespace runs left behind by the scrubs below.
|
||||||
// The raw event log keeps the unscrubbed title; only bucket keys are scrubbed.
|
var extraSpace = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
// ScrubTitle normalizes a window title into a stable bucket key. It drops
|
||||||
|
// volatile numeric runs (clocks, ratios, percentages) and any non-ASCII symbol
|
||||||
|
// or control rune. The latter covers the animated spinner frames terminal apps
|
||||||
|
// (e.g. Claude Code) cycle through their title — braille and dingbat glyphs are
|
||||||
|
// both Unicode symbol category, so a single task no longer fragments into dozens
|
||||||
|
// of buckets. Letters and digits of every script survive, as do ASCII symbols
|
||||||
|
// (so "C++" is not mangled). Leftover whitespace is collapsed. The raw event log
|
||||||
|
// keeps the unscrubbed title; only bucket keys (and their display) are scrubbed.
|
||||||
func ScrubTitle(title string) string {
|
func ScrubTitle(title string) string {
|
||||||
return titleNoise.ReplaceAllString(title, "")
|
title = titleNoise.ReplaceAllString(title, "")
|
||||||
|
title = strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsControl(r) || (r > unicode.MaxASCII && unicode.IsSymbol(r)) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, title)
|
||||||
|
return strings.TrimSpace(extraSpace.ReplaceAllString(title, " "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ import (
|
|||||||
|
|
||||||
func TestScrubTitle(t *testing.T) {
|
func TestScrubTitle(t *testing.T) {
|
||||||
cases := []struct{ in, want string }{
|
cases := []struct{ in, want string }{
|
||||||
{"Plain title", "Plain title"}, // no digits-with-separator: untouched
|
{"Plain title", "Plain title"}, // no digits-with-separator: untouched
|
||||||
{"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory)
|
{"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory)
|
||||||
{"12.5%", ""}, // percent decimal: stripped whole
|
{"12.5%", ""}, // percent decimal: stripped whole
|
||||||
{"1:23:45 remaining", " remaining"}, // clock ratio: stripped
|
{"1:23:45 remaining", "remaining"}, // clock ratio: stripped, space collapsed
|
||||||
{"-3.0 delta", " delta"}, // leading sign + decimal: stripped
|
{"-3.0 delta", "delta"}, // leading sign + decimal: stripped
|
||||||
{"Download 50.5% complete", "Download complete"}, // embedded percent decimal
|
{"Download 50.5% complete", "Download complete"}, // embedded percent decimal
|
||||||
|
{"⠸ antidrift", "antidrift"}, // braille spinner frame stripped
|
||||||
|
{"⠼ antidrift", "antidrift"}, // different frame collapses to same key
|
||||||
|
{"✳ Check latest commit date", "Check latest commit date"}, // dingbat-star indicator
|
||||||
|
{"⠂ Check latest commit date", "Check latest commit date"}, // same task, different frame
|
||||||
|
{"✳ ⠼ done", "done"}, // multiple glyphs in one title
|
||||||
|
{"C++ build", "C++ build"}, // ASCII symbols kept (not all-symbols)
|
||||||
|
{"日本語 window", "日本語 window"}, // non-ASCII letters preserved
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := ScrubTitle(c.in); got != c.want {
|
if got := ScrubTitle(c.in); got != c.want {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxProfileBytes caps the grounding text so the coach prompt stays bounded.
|
||||||
|
const maxProfileBytes = 6 * 1024
|
||||||
|
|
||||||
|
// FileSource reads the user's profile from a single file. It is stateless: the
|
||||||
|
// selected path is passed to Load, so the controller can repoint it at runtime
|
||||||
|
// without a mutable field.
|
||||||
|
type FileSource struct {
|
||||||
|
defaultPath string // used when Load is called with path == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileSource builds the adapter. defaultPath is used when Load receives an
|
||||||
|
// empty path; if it too is empty, Load falls back to ~/.keel/knowledge.md.
|
||||||
|
func NewFileSource(defaultPath string) *FileSource {
|
||||||
|
return &FileSource{defaultPath: defaultPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the profile at path (or the default). A missing file yields an
|
||||||
|
// empty-Text Profile and no error; only a real read failure errors. The
|
||||||
|
// resolved absolute path is always returned for display.
|
||||||
|
func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) {
|
||||||
|
resolved := s.resolve(path)
|
||||||
|
data, err := os.ReadFile(resolved)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return Profile{Path: resolved}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Profile{Path: resolved}, fmt.Errorf("knowledge: read %s: %w", resolved, err)
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(string(data))
|
||||||
|
if text == "" {
|
||||||
|
return Profile{Path: resolved}, nil
|
||||||
|
}
|
||||||
|
return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve picks path, else the default, else ~/.keel/knowledge.md; expands
|
||||||
|
// a leading ~; and makes the result absolute for stable display.
|
||||||
|
func (s *FileSource) resolve(path string) string {
|
||||||
|
p := path
|
||||||
|
if p == "" {
|
||||||
|
p = s.defaultPath
|
||||||
|
}
|
||||||
|
if p == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
p = filepath.Join(home, ".keel", "knowledge.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p = expandTilde(p)
|
||||||
|
if abs, err := filepath.Abs(p); err == nil {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandTilde(p string) string {
|
||||||
|
if p != "~" && !strings.HasPrefix(p, "~/") {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if p == "~" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return filepath.Join(home, p[2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate clips s to max bytes on a UTF-8 rune boundary and appends a marker.
|
||||||
|
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,98 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadPresentFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "knowledge.md")
|
||||||
|
if err := os.WriteFile(p, []byte(" I am a focus-tool author.\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "I am a focus-tool author." {
|
||||||
|
t.Errorf("Text = %q (whitespace should be trimmed)", got.Text)
|
||||||
|
}
|
||||||
|
if got.Path != p {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Path, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAbsentFileIsNotError(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "missing.md")
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("absent file must not error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "" {
|
||||||
|
t.Errorf("Text = %q, want empty for absent file", got.Text)
|
||||||
|
}
|
||||||
|
if got.Path != p {
|
||||||
|
t.Errorf("Path = %q, want %q even when absent", got.Path, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadExplicitPathBeatsDefault(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
def := filepath.Join(dir, "default.md")
|
||||||
|
other := filepath.Join(dir, "other.md")
|
||||||
|
if err := os.WriteFile(def, []byte("default"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(other, []byte("explicit"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(def).Load(context.Background(), other)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "explicit" || got.Path != other {
|
||||||
|
t.Fatalf("explicit path ignored: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadTruncatesOversize(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "big.md")
|
||||||
|
if err := os.WriteFile(p, []byte(strings.Repeat("a", maxProfileBytes+500)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Text) > maxProfileBytes+len("\n…(truncated)") {
|
||||||
|
t.Errorf("Text not truncated: %d bytes", len(got.Text))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got.Text, "(truncated)") {
|
||||||
|
t.Errorf("missing truncation marker: %q", got.Text[len(got.Text)-20:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadWhitespaceOnlyIsAbsent(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "blank.md")
|
||||||
|
if err := os.WriteFile(p, []byte(" \n\t\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewFileSource(p).Load(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "" {
|
||||||
|
t.Errorf("whitespace-only should be empty, got %q", got.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadReadErrorIsError(t *testing.T) {
|
||||||
|
dir := t.TempDir() // a directory is not readable as a file
|
||||||
|
if _, err := NewFileSource(dir).Load(context.Background(), ""); err == nil {
|
||||||
|
t.Fatal("reading a directory should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package knowledge is the Knowledge port: it answers "who am I; what are my
|
||||||
|
// priorities?" by loading the user's standing profile. It imports nothing from
|
||||||
|
// the rest of the app, so it stays a leaf package.
|
||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Profile is the user's standing context. Primitives only, so knowledge stays a
|
||||||
|
// leaf package.
|
||||||
|
type Profile struct {
|
||||||
|
Text string // grounding text; "" when no profile is available
|
||||||
|
Path string // resolved source location, for display
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source answers "who am I; what are my priorities?" — the user's standing
|
||||||
|
// profile that grounds the advisor.
|
||||||
|
type Source interface {
|
||||||
|
// Load returns the user's profile. path selects an explicit location; ""
|
||||||
|
// means the adapter's configured default. A missing source is NOT an error:
|
||||||
|
// it yields an empty-Text Profile so the caller degrades to ungrounded. Only
|
||||||
|
// a real read failure (permissions, unreadable) returns an error.
|
||||||
|
Load(ctx context.Context, path string) (Profile, error)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package focus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/enforce"
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
"keel/internal/store"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driftDebounce = 10 * time.Second
|
||||||
|
driftTimeout = 30 * time.Second
|
||||||
|
enforceTimeout = 5 * time.Second
|
||||||
|
nudgeDebounce = 5 * time.Minute
|
||||||
|
nudgeTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
const recentTitlesMax = 10
|
||||||
|
|
||||||
|
const (
|
||||||
|
driftIdle = "idle"
|
||||||
|
driftPending = "pending"
|
||||||
|
driftOnTask = "ontask"
|
||||||
|
driftDrifting = "drifting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
||||||
|
// working; unmatched windows simply stay idle.
|
||||||
|
func (c *Mode) SetDriftJudge(j ai.DriftJudge) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.judge = j
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
|
||||||
|
// enforcement; everything else behaves identically.
|
||||||
|
func (c *Mode) SetGuard(g enforce.Guard) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.guard = g
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||||
|
// local matching and the drift judge are unaffected.
|
||||||
|
func (c *Mode) SetNudge(n ai.Nudger) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.nudge = n
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitmentLineLocked renders the active commitment as a single line for AI
|
||||||
|
// prompts, or "" if there is none. Caller holds mu.
|
||||||
|
func (c *Mode) commitmentLineLocked() string {
|
||||||
|
if c.commitment == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
||||||
|
func (c *Mode) recentTitlesForTest() []string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return append([]string(nil), c.recentTitles...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||||
|
func (c *Mode) resetDriftLocked() {
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.driftReason = ""
|
||||||
|
c.driftGen++
|
||||||
|
c.nudgeEpoch++
|
||||||
|
c.lastJudgedAt = time.Time{}
|
||||||
|
c.judgedWindows = map[string]ai.Verdict{}
|
||||||
|
c.recentTitles = nil
|
||||||
|
c.nudgeMessage = ""
|
||||||
|
c.lastNudgedAt = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnTask appends the current window class to the session allowed-context, clears
|
||||||
|
// drift, drops the cached verdict for the current window, and persists. The
|
||||||
|
// class now matches locally and is never re-judged.
|
||||||
|
func (c *Mode) OnTask() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.runtimeState != domain.RuntimeActive {
|
||||||
|
return ErrNotActive
|
||||||
|
}
|
||||||
|
var class string
|
||||||
|
if c.stats != nil {
|
||||||
|
class = c.stats.Current.Class
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(class) != "" {
|
||||||
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||||
|
if !evidence.MatchesAllowed(ac, class, "") {
|
||||||
|
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
||||||
|
}
|
||||||
|
delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title))
|
||||||
|
}
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refocus clears the current drift verdict without changing allowed-context, and
|
||||||
|
// drops the cached verdict for the current window so it may be judged again later.
|
||||||
|
func (c *Mode) Refocus() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.runtimeState != domain.RuntimeActive {
|
||||||
|
return ErrNotActive
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title))
|
||||||
|
}
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.driftReason = ""
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWindow ingests one sensor observation. It accumulates time only while
|
||||||
|
// Active, always tracks the latest window for display, and fires onChange after
|
||||||
|
// releasing the mutex.
|
||||||
|
func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.latestWindow = snap
|
||||||
|
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := c.clock()
|
||||||
|
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
||||||
|
c.applyEvent(now, snap)
|
||||||
|
c.recordTitleLocked(snap.Title)
|
||||||
|
launch := c.evaluateDriftLocked(now, snap)
|
||||||
|
enforceAct := c.enforceActionLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
if launch != nil {
|
||||||
|
go launch()
|
||||||
|
}
|
||||||
|
if enforceAct != nil {
|
||||||
|
go enforceAct()
|
||||||
|
}
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceActionLocked returns the minimize thunk when this observation should be
|
||||||
|
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
|
||||||
|
// returned func performs blocking X11 I/O and MUST run after the caller releases
|
||||||
|
// c.mu, following the off-lock async-I/O discipline used by the other roles.
|
||||||
|
func (c *Mode) enforceActionLocked() func() {
|
||||||
|
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard := c.guard
|
||||||
|
return func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := guard.MinimizeActive(ctx); err != nil {
|
||||||
|
log.Printf("focus: enforce minimize failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
||||||
|
// and updates synchronous drift state. When an async judgment is warranted it
|
||||||
|
// returns the judging closure; the caller runs it in a goroutine after
|
||||||
|
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
||||||
|
// already verified the runtime is Active.
|
||||||
|
func (c *Mode) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
|
||||||
|
class, title := snap.Class, snap.Title
|
||||||
|
|
||||||
|
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
||||||
|
// where the semantic nudge runs — the drift judge stays silent here, so the
|
||||||
|
// two are mutually exclusive per observation.
|
||||||
|
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||||
|
if evidence.MatchesAllowed(ac, class, title) {
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return c.maybeNudgeLocked(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past this point the window is not a local on-task match: the on-task
|
||||||
|
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
|
||||||
|
// epoch so an in-flight nudge result is discarded rather than surfacing stale
|
||||||
|
// when the user returns.
|
||||||
|
c.nudgeMessage = ""
|
||||||
|
c.nudgeEpoch++
|
||||||
|
|
||||||
|
// 2. Per-window cache, keyed by class+title. A verdict for one window of an
|
||||||
|
// 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)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No judge wired: never block; leave idle.
|
||||||
|
if c.judge == nil {
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.driftReason = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Debounce: at most one judgment per driftDebounce window.
|
||||||
|
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prevStatus, prevReason := c.driftStatus, c.driftReason
|
||||||
|
c.driftGen++
|
||||||
|
gen := c.driftGen
|
||||||
|
c.lastJudgedAt = now
|
||||||
|
c.driftStatus = driftPending
|
||||||
|
c.driftReason = ""
|
||||||
|
judge := c.judge
|
||||||
|
commitment := c.commitmentLineLocked()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
||||||
|
defer cancel()
|
||||||
|
v, err := judge.JudgeDrift(ctx, commitment, class, title)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // stale: a newer judgment started or the session ended
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Degrade: never fabricate drift. Revert the optimistic pending.
|
||||||
|
if c.driftStatus == driftPending {
|
||||||
|
c.driftStatus = prevStatus
|
||||||
|
c.driftReason = prevReason
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
log.Printf("focus: drift judge failed: %v", err)
|
||||||
|
c.notify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.judgedWindows[key] = v
|
||||||
|
var enforceAct func()
|
||||||
|
if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key {
|
||||||
|
c.applyVerdictLocked(v)
|
||||||
|
enforceAct = c.enforceActionLocked()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if enforceAct != nil {
|
||||||
|
enforceAct()
|
||||||
|
}
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
|
||||||
|
// local-match path. It returns the nudging closure when eligible (judge wired,
|
||||||
|
// at least two titles of history, debounce elapsed), else nil. The closure
|
||||||
|
// follows the drift-judge discipline: it runs in a goroutine after the caller
|
||||||
|
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
|
||||||
|
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
|
||||||
|
// episode or a session change — is discarded rather than surfacing stale, never
|
||||||
|
// fabricates a concern on error, and notifies only after unlocking. Caller holds
|
||||||
|
// mu and has already verified the runtime is Active.
|
||||||
|
func (c *Mode) maybeNudgeLocked(now time.Time) func() {
|
||||||
|
if c.nudge == nil || len(c.recentTitles) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.lastNudgedAt = now
|
||||||
|
epoch := c.nudgeEpoch
|
||||||
|
nudge := c.nudge
|
||||||
|
commitment := c.commitmentLineLocked()
|
||||||
|
titles := append([]string(nil), c.recentTitles...)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
msg, err := nudge.Nudge(ctx, commitment, titles)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // stale: the on-task stretch ended (drift episode) or the session changed
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.mu.Unlock()
|
||||||
|
log.Printf("focus: nudge failed: %v", err)
|
||||||
|
return // never fabricate a concern; leave any prior message intact
|
||||||
|
}
|
||||||
|
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
||||||
|
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
||||||
|
func (c *Mode) recordTitleLocked(title string) {
|
||||||
|
t := strings.TrimSpace(title)
|
||||||
|
if t == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.recentTitles = append(c.recentTitles, t)
|
||||||
|
if len(c.recentTitles) > recentTitlesMax {
|
||||||
|
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (c *Mode) applyVerdictLocked(v ai.Verdict) {
|
||||||
|
if v.OnTask {
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.driftReason = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.driftStatus = driftDrifting
|
||||||
|
c.driftReason = v.Reason
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
// Package focus owns the daemon's in-memory state of truth and persists a
|
||||||
|
// snapshot on every change. Transitions go through the pure statemachine. It
|
||||||
|
// also owns per-session evidence stats: it accumulates active-window time while
|
||||||
|
// Active, logs raw focus events, and seals each session into the audit chain.
|
||||||
|
package focus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/enforce"
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/harness"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
"keel/internal/mode/focus/statemachine"
|
||||||
|
"keel/internal/store"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
unavailableTitle = "(evidence unavailable)"
|
||||||
|
sessionRetention = 30 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotPlanning = errors.New("focus: coaching is only available while planning")
|
||||||
|
|
||||||
|
var ErrNotActive = errors.New("focus: only available while a commitment is active")
|
||||||
|
|
||||||
|
// Mode holds runtime state and the active commitment behind a mutex.
|
||||||
|
type Mode struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
async harness.Async
|
||||||
|
runtimeState domain.RuntimeState
|
||||||
|
commitment *domain.Commitment
|
||||||
|
deadline time.Time
|
||||||
|
snapshotPath string
|
||||||
|
auditPath string
|
||||||
|
sessionsDir string
|
||||||
|
clock func() time.Time
|
||||||
|
onChanges []func()
|
||||||
|
latestWindow evidence.WindowSnapshot
|
||||||
|
stats *EvidenceStats
|
||||||
|
outcomePending string
|
||||||
|
coach ai.Coach
|
||||||
|
coachStatus string
|
||||||
|
coachProposal *ai.Proposal
|
||||||
|
coachErr string
|
||||||
|
coachGen int
|
||||||
|
|
||||||
|
tasksProvider tasks.Provider
|
||||||
|
tasksStatus string
|
||||||
|
tasksList []tasks.Task
|
||||||
|
tasksGen int
|
||||||
|
|
||||||
|
knowledgeSrc knowledge.Source
|
||||||
|
knowledgeStatus string
|
||||||
|
knowledgeText string // cached grounding the coach reads
|
||||||
|
knowledgePath string // selected path; "" = adapter default
|
||||||
|
knowledgeChars int
|
||||||
|
knowledgeGen int
|
||||||
|
|
||||||
|
reviewer ai.Reviewer
|
||||||
|
reflectionStatus string
|
||||||
|
reflectionRecap string
|
||||||
|
carryForward string // latest-wins takeaway; grounds the next coach
|
||||||
|
reflectionGen int
|
||||||
|
|
||||||
|
allowedClasses []string // durable: the active session's allowed window classes
|
||||||
|
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
|
||||||
|
guard enforce.Guard
|
||||||
|
judge ai.DriftJudge
|
||||||
|
driftStatus string
|
||||||
|
driftReason string
|
||||||
|
driftGen int
|
||||||
|
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
|
||||||
|
lastJudgedAt time.Time
|
||||||
|
judgedWindows map[string]ai.Verdict
|
||||||
|
|
||||||
|
nudge ai.Nudger
|
||||||
|
recentTitles []string // in-memory ring of recent distinct titles this session
|
||||||
|
nudgeMessage string // current soft advisory ("" = none)
|
||||||
|
lastNudgedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
||||||
|
// in-memory stats from the raw log if a live session was interrupted.
|
||||||
|
func New(snapshotPath string) (*Mode, error) {
|
||||||
|
s, err := store.Load(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(snapshotPath)
|
||||||
|
c := &Mode{
|
||||||
|
runtimeState: s.RuntimeState,
|
||||||
|
commitment: s.Commitment,
|
||||||
|
snapshotPath: snapshotPath,
|
||||||
|
auditPath: filepath.Join(dir, "audit.jsonl"),
|
||||||
|
sessionsDir: filepath.Join(dir, "sessions"),
|
||||||
|
clock: time.Now,
|
||||||
|
outcomePending: s.OutcomePending,
|
||||||
|
reflectionStatus: s.ReflectionStatus,
|
||||||
|
reflectionRecap: s.ReflectionRecap,
|
||||||
|
carryForward: s.CarryForward,
|
||||||
|
}
|
||||||
|
if s.DeadlineUnixSecs > 0 {
|
||||||
|
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
|
||||||
|
}
|
||||||
|
if c.runtimeState == "" {
|
||||||
|
c.runtimeState = domain.RuntimeLocked
|
||||||
|
}
|
||||||
|
c.async = harness.NewAsync(&c.mu, c.notify)
|
||||||
|
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
||||||
|
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
||||||
|
c.allowedClasses = s.AllowedWindowClasses
|
||||||
|
c.enforcementLevel = s.EnforcementLevel
|
||||||
|
// Drift state is not persisted: recompute fresh after restart to avoid
|
||||||
|
// stale interrupts. This also initializes judgedWindows (the pipeline
|
||||||
|
// writes to it) so a restored Active session never panics on a nil map.
|
||||||
|
c.resetDriftLocked()
|
||||||
|
c.replayStats(s.SessionID)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock overrides the time source (tests only). Call before starting a
|
||||||
|
// session.
|
||||||
|
func (c *Mode) SetClock(f func() time.Time) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.clock = f
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnChange registers the sole change listener, replacing any already set. A
|
||||||
|
// listener is fired after a state change (focus updates, role results) with the
|
||||||
|
// mutex released. Use AddOnChange to register additional listeners.
|
||||||
|
func (c *Mode) SetOnChange(f func()) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.onChanges = []func(){f}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.onChanges = append(c.onChanges, f)
|
||||||
|
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 {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State returns the current broadcastable state. Safe for concurrent use.
|
||||||
|
func (c *Mode) State() State {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.stateLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline returns the active commitment deadline, or the zero time.
|
||||||
|
func (c *Mode) Deadline() time.Time {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.deadline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Mode) persistLocked() error {
|
||||||
|
snap := store.Snapshot{
|
||||||
|
RuntimeState: c.runtimeState,
|
||||||
|
Commitment: c.commitment,
|
||||||
|
OutcomePending: c.outcomePending,
|
||||||
|
}
|
||||||
|
if !c.deadline.IsZero() {
|
||||||
|
snap.DeadlineUnixSecs = c.deadline.Unix()
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
snap.SessionID = c.stats.SessionID
|
||||||
|
}
|
||||||
|
snap.AllowedWindowClasses = c.allowedClasses
|
||||||
|
snap.EnforcementLevel = c.enforcementLevel
|
||||||
|
snap.ReflectionStatus = c.reflectionStatus
|
||||||
|
snap.ReflectionRecap = c.reflectionRecap
|
||||||
|
snap.CarryForward = c.carryForward
|
||||||
|
return store.Save(c.snapshotPath, snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnterPlanning moves Locked -> Planning.
|
||||||
|
func (c *Mode) EnterPlanning() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.runtimeState = next
|
||||||
|
c.resetCoachLocked()
|
||||||
|
c.startTasksFetchLocked()
|
||||||
|
c.startKnowledgeFetchLocked()
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedClassesForTest exposes the session allowed classes for tests.
|
||||||
|
func (c *Mode) AllowedClassesForTest() []string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return append([]string(nil), c.allowedClasses...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
|
||||||
|
// only.
|
||||||
|
func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.enforcementLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartManualCommitment validates input, activates a new commitment, mints a
|
||||||
|
// session, seeds evidence stats from the latest window, and moves Planning ->
|
||||||
|
// Active.
|
||||||
|
func (c *Mode) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := c.clock()
|
||||||
|
c.runtimeState = next
|
||||||
|
c.commitment = &commitment
|
||||||
|
c.deadline = now.Add(timebox)
|
||||||
|
c.outcomePending = ""
|
||||||
|
c.resetCoachLocked()
|
||||||
|
c.allowedClasses = append([]string(nil), allowedClasses...)
|
||||||
|
c.enforcementLevel = level
|
||||||
|
c.resetDriftLocked()
|
||||||
|
|
||||||
|
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
||||||
|
c.stats = &EvidenceStats{
|
||||||
|
SessionID: sessionID,
|
||||||
|
StartedUnix: now.Unix(),
|
||||||
|
Buckets: map[bucketKey]time.Duration{},
|
||||||
|
OnTask: map[bucketKey]time.Duration{},
|
||||||
|
OffTask: map[bucketKey]time.Duration{},
|
||||||
|
}
|
||||||
|
seed := c.latestWindow
|
||||||
|
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
|
||||||
|
c.applyEvent(now, seed)
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete moves Active -> Review with a "completed" outcome.
|
||||||
|
func (c *Mode) Complete() error { return c.enterReview("completed") }
|
||||||
|
|
||||||
|
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
|
||||||
|
func (c *Mode) Expire() error { return c.enterReview("expired") }
|
||||||
|
|
||||||
|
func (c *Mode) enterReview(outcome string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.commitment != nil {
|
||||||
|
completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.commitment.State = completed
|
||||||
|
}
|
||||||
|
// Flush the final open segment, then freeze accounting.
|
||||||
|
if c.stats != nil && c.stats.hasLast {
|
||||||
|
c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt))
|
||||||
|
c.stats.hasLast = false
|
||||||
|
}
|
||||||
|
c.runtimeState = next
|
||||||
|
c.outcomePending = outcome
|
||||||
|
c.startReflectionFetchLocked()
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// End moves Review -> Locked, writes the session summary to the audit chain,
|
||||||
|
// and clears the commitment and stats.
|
||||||
|
func (c *Mode) End() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
|
||||||
|
// State integrity over audit completeness: the transition still
|
||||||
|
// completes. Surfaced for the operator; no auto-retry in M1.
|
||||||
|
log.Printf("focus: audit append failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.runtimeState = next
|
||||||
|
c.commitment = nil
|
||||||
|
c.deadline = time.Time{}
|
||||||
|
c.stats = nil
|
||||||
|
c.outcomePending = ""
|
||||||
|
c.allowedClasses = nil
|
||||||
|
c.enforcementLevel = ""
|
||||||
|
c.resetDriftLocked()
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Mode) buildSummaryLocked() store.SessionSummary {
|
||||||
|
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
|
||||||
|
for k, d := range c.stats.Buckets {
|
||||||
|
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
||||||
|
}
|
||||||
|
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds })
|
||||||
|
outcome := c.outcomePending
|
||||||
|
if outcome == "" {
|
||||||
|
outcome = "completed"
|
||||||
|
}
|
||||||
|
var na, sc string
|
||||||
|
if c.commitment != nil {
|
||||||
|
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
||||||
|
}
|
||||||
|
return store.SessionSummary{
|
||||||
|
SessionID: c.stats.SessionID,
|
||||||
|
NextAction: na,
|
||||||
|
SuccessCond: sc,
|
||||||
|
Outcome: outcome,
|
||||||
|
StartedUnix: c.stats.StartedUnix,
|
||||||
|
EndedUnix: c.clock().Unix(),
|
||||||
|
SwitchCount: c.stats.SwitchCount,
|
||||||
|
Buckets: buckets,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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) }
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package focus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"keel/internal/ai"
|
||||||
|
"keel/internal/knowledge"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
"keel/internal/store"
|
||||||
|
"keel/internal/tasks"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const coachTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
coachIdle = "idle"
|
||||||
|
coachPending = "pending"
|
||||||
|
coachReady = "ready"
|
||||||
|
coachError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasksTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
tasksIdle = "idle"
|
||||||
|
tasksPending = "pending"
|
||||||
|
tasksReady = "ready"
|
||||||
|
tasksError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
const knowledgeTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
knowledgeIdle = "idle"
|
||||||
|
knowledgePending = "pending"
|
||||||
|
knowledgeReady = "ready"
|
||||||
|
knowledgeAbsent = "absent"
|
||||||
|
knowledgeError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
const reflectionTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
const reflectionHistoryN = 5
|
||||||
|
|
||||||
|
// reflectionTopBuckets caps how many per-window time buckets the finished-session
|
||||||
|
// block lists for the reviewer, keeping the prompt compact.
|
||||||
|
const reflectionTopBuckets = 3
|
||||||
|
|
||||||
|
const (
|
||||||
|
reflectionIdle = "idle"
|
||||||
|
reflectionPending = "pending"
|
||||||
|
reflectionReady = "ready"
|
||||||
|
reflectionAbsent = "absent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
||||||
|
// RequestCoach degrade gracefully.
|
||||||
|
func (c *Mode) SetCoach(coach ai.Coach) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.coach = coach
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetCoachLocked returns coach state to idle and invalidates any in-flight
|
||||||
|
// request. Caller holds mu.
|
||||||
|
func (c *Mode) resetCoachLocked() {
|
||||||
|
c.coachStatus = coachIdle
|
||||||
|
c.coachProposal = nil
|
||||||
|
c.coachErr = ""
|
||||||
|
c.coachGen++
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
|
||||||
|
// the planning tasks list absent.
|
||||||
|
func (c *Mode) SetTasks(p tasks.Provider) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.tasksProvider = p
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
|
||||||
|
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
|
||||||
|
// post-planning results, and notifies on completion. Caller holds mu.
|
||||||
|
func (c *Mode) startTasksFetchLocked() {
|
||||||
|
c.tasksList = nil
|
||||||
|
if c.tasksProvider == nil {
|
||||||
|
c.tasksStatus = tasksIdle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.tasksGen++
|
||||||
|
gen := c.tasksGen
|
||||||
|
c.tasksStatus = tasksPending
|
||||||
|
provider := c.tasksProvider
|
||||||
|
var list []tasks.Task
|
||||||
|
var err error
|
||||||
|
c.async.Run(tasksTimeout,
|
||||||
|
func(ctx context.Context) { list, err = provider.Today(ctx) },
|
||||||
|
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
|
||||||
|
func() {
|
||||||
|
if err != nil {
|
||||||
|
c.tasksStatus = tasksError
|
||||||
|
c.tasksList = nil
|
||||||
|
} else {
|
||||||
|
c.tasksStatus = tasksReady
|
||||||
|
c.tasksList = list
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
|
||||||
|
// keeps the planning knowledge view absent and the coach ungrounded.
|
||||||
|
func (c *Mode) SetKnowledge(s knowledge.Source) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.knowledgeSrc = s
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKnowledgePath selects an explicit profile path (session-only; not
|
||||||
|
// persisted). While planning, it re-loads immediately so the indicator and the
|
||||||
|
// cached grounding update. An empty path resets to the adapter default.
|
||||||
|
func (c *Mode) SetKnowledgePath(path string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.knowledgePath = strings.TrimSpace(path)
|
||||||
|
planning := c.runtimeState == domain.RuntimePlanning
|
||||||
|
if planning {
|
||||||
|
c.startKnowledgeFetchLocked()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if planning {
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set.
|
||||||
|
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
|
||||||
|
// post-planning results, and notifies on completion. The loaded text is cached
|
||||||
|
// in knowledgeText for the coach to read. Caller holds mu.
|
||||||
|
func (c *Mode) startKnowledgeFetchLocked() {
|
||||||
|
c.knowledgeText = ""
|
||||||
|
c.knowledgeChars = 0
|
||||||
|
if c.knowledgeSrc == nil {
|
||||||
|
c.knowledgeStatus = knowledgeIdle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.knowledgeGen++
|
||||||
|
gen := c.knowledgeGen
|
||||||
|
c.knowledgeStatus = knowledgePending
|
||||||
|
src := c.knowledgeSrc
|
||||||
|
path := c.knowledgePath
|
||||||
|
var prof knowledge.Profile
|
||||||
|
var err error
|
||||||
|
c.async.Run(knowledgeTimeout,
|
||||||
|
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
|
||||||
|
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
|
||||||
|
func() {
|
||||||
|
if err != nil {
|
||||||
|
c.knowledgeStatus = knowledgeError
|
||||||
|
c.knowledgeText = ""
|
||||||
|
c.knowledgeChars = 0
|
||||||
|
if prof.Path != "" {
|
||||||
|
c.knowledgePath = prof.Path
|
||||||
|
}
|
||||||
|
} else if prof.Text == "" {
|
||||||
|
c.knowledgeStatus = knowledgeAbsent
|
||||||
|
c.knowledgeText = ""
|
||||||
|
c.knowledgeChars = 0
|
||||||
|
c.knowledgePath = prof.Path
|
||||||
|
} else {
|
||||||
|
c.knowledgeStatus = knowledgeReady
|
||||||
|
c.knowledgeText = prof.Text
|
||||||
|
c.knowledgeChars = len(prof.Text)
|
||||||
|
c.knowledgePath = prof.Path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
|
||||||
|
// leaves the coach ungrounded by any carry-forward.
|
||||||
|
func (c *Mode) SetReviewer(r ai.Reviewer) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.reviewer = r
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// startReflectionFetchLocked kicks off an asynchronous reflection when a
|
||||||
|
// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the
|
||||||
|
// completion guard is generation-only (not state-gated): the carry-forward must
|
||||||
|
// still apply if the user clicks End before the reviewer returns. A superseded
|
||||||
|
// review (a later session's fetch) bumps the generation and discards this one.
|
||||||
|
// The recap and carry-forward are cleared up front so a failed/slow reviewer
|
||||||
|
// never leaves stale data from the previous session. Caller holds mu.
|
||||||
|
func (c *Mode) startReflectionFetchLocked() {
|
||||||
|
c.reflectionRecap = ""
|
||||||
|
c.carryForward = ""
|
||||||
|
if c.reviewer == nil {
|
||||||
|
c.reflectionStatus = reflectionIdle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.reflectionGen++
|
||||||
|
gen := c.reflectionGen
|
||||||
|
c.reflectionStatus = reflectionPending
|
||||||
|
reviewer := c.reviewer
|
||||||
|
finished := c.buildReflectionFinishedLocked()
|
||||||
|
// Read the history synchronously, here under the lock, on purpose: it must
|
||||||
|
// happen-before End appends the just-finished session to the audit chain, so
|
||||||
|
// that session is excluded from "recent history" and not double-counted (it
|
||||||
|
// is already carried in `finished`). Moving this into the goroutine would
|
||||||
|
// race with End's append and reintroduce that double-count. The read is
|
||||||
|
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
|
||||||
|
// on any hot path.
|
||||||
|
history := buildReflectionHistory(c.auditPath)
|
||||||
|
var refl ai.Reflection
|
||||||
|
var err error
|
||||||
|
c.async.Run(reflectionTimeout,
|
||||||
|
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
|
||||||
|
func() bool { return gen != c.reflectionGen },
|
||||||
|
func() {
|
||||||
|
if err != nil || strings.TrimSpace(refl.Recap) == "" {
|
||||||
|
c.reflectionStatus = reflectionAbsent
|
||||||
|
c.reflectionRecap = ""
|
||||||
|
c.carryForward = ""
|
||||||
|
} else {
|
||||||
|
c.reflectionStatus = reflectionReady
|
||||||
|
c.reflectionRecap = refl.Recap
|
||||||
|
c.carryForward = refl.CarryForward
|
||||||
|
}
|
||||||
|
_ = c.persistLocked()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReflectionFinishedLocked renders the just-finished session as a compact
|
||||||
|
// block for the reviewer: the commitment, the outcome, on/off/unclassified time
|
||||||
|
// totals, and a top-N on-task list and top-N off-task list. The split fields are
|
||||||
|
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
|
||||||
|
// set (End clears them, but enterReview runs before End).
|
||||||
|
func (c *Mode) buildReflectionFinishedLocked() string {
|
||||||
|
var na, sc string
|
||||||
|
if c.commitment != nil {
|
||||||
|
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
||||||
|
}
|
||||||
|
outcome := c.outcomePending
|
||||||
|
if outcome == "" {
|
||||||
|
outcome = "completed"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Next action: %s\n", na)
|
||||||
|
fmt.Fprintf(&b, "Success condition: %s\n", sc)
|
||||||
|
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
|
||||||
|
if c.stats != nil {
|
||||||
|
onMin := int64(sumDurations(c.stats.OnTask).Seconds()) / 60
|
||||||
|
offMin := int64(sumDurations(c.stats.OffTask).Seconds()) / 60
|
||||||
|
unclMin := int64(c.stats.unclassified.Seconds()) / 60
|
||||||
|
fmt.Fprintf(&b, "On-task %dm / Off-task %dm / Unclassified %dm\n", onMin, offMin, unclMin)
|
||||||
|
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
|
||||||
|
writeBucketList(&b, "On-task", c.stats.OnTask)
|
||||||
|
writeBucketList(&b, "Off-task", c.stats.OffTask)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBucketList renders a labeled, time-descending list of buckets capped at
|
||||||
|
// reflectionTopBuckets. It writes nothing — not even the label — when the map is
|
||||||
|
// empty, so a single-sided session shows only the list that has time.
|
||||||
|
func writeBucketList(b *strings.Builder, label string, m map[bucketKey]time.Duration) {
|
||||||
|
views := bucketViews(m)
|
||||||
|
if len(views) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(b, "%s:\n", label)
|
||||||
|
for i, bv := range views {
|
||||||
|
if i >= reflectionTopBuckets {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumDurations totals the durations in a bucket map.
|
||||||
|
func sumDurations(m map[bucketKey]time.Duration) time.Duration {
|
||||||
|
var total time.Duration
|
||||||
|
for _, d := range m {
|
||||||
|
total += d
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReflectionHistory renders the last few prior sessions as compact lines.
|
||||||
|
// The just-finished session is not yet in the chain (End appends it), so it is
|
||||||
|
// not double-counted. Returns "" when there is no usable history.
|
||||||
|
func buildReflectionHistory(auditPath string) string {
|
||||||
|
sums, err := store.RecentSessions(auditPath, reflectionHistoryN)
|
||||||
|
if err != nil || len(sums) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, s := range sums {
|
||||||
|
top := ""
|
||||||
|
if len(s.Buckets) > 0 {
|
||||||
|
top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// composedGroundingLocked combines the standing profile (knowledge port) with
|
||||||
|
// the latest carry-forward takeaway into the single free-form grounding string
|
||||||
|
// the coach already accepts. Caller holds mu.
|
||||||
|
func (c *Mode) composedGroundingLocked() string {
|
||||||
|
g := c.knowledgeText
|
||||||
|
if c.carryForward != "" {
|
||||||
|
if g != "" {
|
||||||
|
g += "\n\n"
|
||||||
|
}
|
||||||
|
g += "Last session's takeaway: " + c.carryForward
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
||||||
|
// not in planning; otherwise never a hard error (failures surface as coach
|
||||||
|
// state). The proposal is ephemeral and never persisted.
|
||||||
|
func (c *Mode) RequestCoach(intent string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.runtimeState != domain.RuntimePlanning {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return ErrNotPlanning
|
||||||
|
}
|
||||||
|
if c.coach == nil {
|
||||||
|
c.coachStatus = coachError
|
||||||
|
c.coachErr = "coach unavailable"
|
||||||
|
c.coachProposal = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.coachGen++
|
||||||
|
gen := c.coachGen
|
||||||
|
c.coachStatus = coachPending
|
||||||
|
c.coachErr = ""
|
||||||
|
c.coachProposal = nil
|
||||||
|
coach := c.coach
|
||||||
|
grounding := c.composedGroundingLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
|
||||||
|
var prop ai.Proposal
|
||||||
|
var err error
|
||||||
|
c.async.Run(coachTimeout,
|
||||||
|
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
|
||||||
|
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
|
||||||
|
func() {
|
||||||
|
if err != nil {
|
||||||
|
c.coachStatus = coachError
|
||||||
|
c.coachErr = coachErrorMessage(err)
|
||||||
|
c.coachProposal = nil
|
||||||
|
} else {
|
||||||
|
c.coachStatus = coachReady
|
||||||
|
c.coachProposal = &prop
|
||||||
|
c.coachErr = ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func coachErrorMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
|
||||||
|
return "coach returned an unusable response"
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return "coach timed out"
|
||||||
|
default:
|
||||||
|
return "coach unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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) {
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package focus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"keel/internal/evidence"
|
||||||
|
"keel/internal/store"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
||||||
|
type bucketKey struct{ Class, Title string }
|
||||||
|
|
||||||
|
// EvidenceStats is the in-memory accounting for the current session only.
|
||||||
|
type EvidenceStats struct {
|
||||||
|
SessionID string
|
||||||
|
StartedUnix int64
|
||||||
|
Buckets map[bucketKey]time.Duration // total time per bucket (unchanged)
|
||||||
|
OnTask map[bucketKey]time.Duration // on-task portion per bucket
|
||||||
|
OffTask map[bucketKey]time.Duration // off-task portion per bucket
|
||||||
|
unclassified time.Duration // idle/pending time; aggregate only
|
||||||
|
SwitchCount int
|
||||||
|
Current evidence.WindowSnapshot
|
||||||
|
lastFocusAt time.Time
|
||||||
|
lastKey bucketKey
|
||||||
|
hasLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEvent advances stats by one observation: it credits the prior segment to
|
||||||
|
// the prior bucket, counts a context switch on key change, and records the new
|
||||||
|
// current window. Used by both live tracking and crash replay. Caller holds mu.
|
||||||
|
func (c *Mode) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
||||||
|
if c.stats.hasLast {
|
||||||
|
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
|
||||||
|
}
|
||||||
|
newKey := keyFor(snap)
|
||||||
|
if c.stats.hasLast && newKey != c.stats.lastKey {
|
||||||
|
c.stats.SwitchCount++
|
||||||
|
}
|
||||||
|
c.stats.lastKey = newKey
|
||||||
|
c.stats.lastFocusAt = now
|
||||||
|
c.stats.hasLast = true
|
||||||
|
c.stats.Current = snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// creditLocked credits duration d to bucket k: always to the running total, and
|
||||||
|
// to the on/off/unclassified split per the live drift status — which, at every
|
||||||
|
// credit site, is the classification of the segment being credited (applyEvent
|
||||||
|
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
|
||||||
|
// before the state transition). idle/pending route to unclassified: honest, never
|
||||||
|
// falsely on-task. Caller holds mu.
|
||||||
|
func (c *Mode) creditLocked(k bucketKey, d time.Duration) {
|
||||||
|
c.stats.Buckets[k] += d
|
||||||
|
switch c.driftStatus {
|
||||||
|
case driftOnTask:
|
||||||
|
c.stats.OnTask[k] += d
|
||||||
|
case driftDrifting:
|
||||||
|
c.stats.OffTask[k] += d
|
||||||
|
default: // driftIdle, driftPending
|
||||||
|
c.stats.unclassified += d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayStats rebuilds in-memory stats from the raw session log after a crash.
|
||||||
|
func (c *Mode) replayStats(sessionID string) {
|
||||||
|
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
||||||
|
if err != nil || len(events) == 0 {
|
||||||
|
c.stats = &EvidenceStats{
|
||||||
|
SessionID: sessionID,
|
||||||
|
StartedUnix: c.clock().Unix(),
|
||||||
|
Buckets: map[bucketKey]time.Duration{},
|
||||||
|
OnTask: map[bucketKey]time.Duration{},
|
||||||
|
OffTask: map[bucketKey]time.Duration{},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.stats = &EvidenceStats{
|
||||||
|
SessionID: sessionID,
|
||||||
|
StartedUnix: events[0].AtUnixMillis / 1000,
|
||||||
|
Buckets: map[bucketKey]time.Duration{},
|
||||||
|
OnTask: map[bucketKey]time.Duration{},
|
||||||
|
OffTask: map[bucketKey]time.Duration{},
|
||||||
|
}
|
||||||
|
// Drift status is not persisted, so replayed pre-crash segments credit to
|
||||||
|
// unclassified (driftStatus is driftIdle here). Totals in Buckets are exact;
|
||||||
|
// only the on/off split degrades — honest, never falsely on-task.
|
||||||
|
for _, e := range events {
|
||||||
|
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyFor(snap evidence.WindowSnapshot) bucketKey {
|
||||||
|
if !snap.Health.Available {
|
||||||
|
return bucketKey{Class: "", Title: unavailableTitle}
|
||||||
|
}
|
||||||
|
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
|
||||||
|
return store.FocusEvent{
|
||||||
|
AtUnixMillis: now.UnixMilli(),
|
||||||
|
Class: snap.Class,
|
||||||
|
Title: snap.Title,
|
||||||
|
Available: snap.Health.Available,
|
||||||
|
Reason: snap.Health.Reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
|
||||||
|
return evidence.WindowSnapshot{
|
||||||
|
Title: e.Title,
|
||||||
|
Class: e.Class,
|
||||||
|
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package focus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommitmentView is the UI projection of the active commitment.
|
||||||
|
type CommitmentView struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProposalView / CoachView project the ephemeral planning-coach state.
|
||||||
|
type ProposalView struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
|
||||||
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriftView is the active-only drift projection. Nudge is a separate axis from
|
||||||
|
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
||||||
|
// inside an allowed app), so it cannot be folded into the status enum.
|
||||||
|
type DriftView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Nudge string `json:"nudge,omitempty"`
|
||||||
|
Enforced bool `json:"enforced,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoachView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Proposal *ProposalView `json:"proposal,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskView is one to-do item in the planning tasks list.
|
||||||
|
type TaskView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Day string `json:"day,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
|
||||||
|
type TasksView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Tasks []TaskView `json:"tasks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KnowledgeView projects the ephemeral planning knowledge state (the standing
|
||||||
|
// profile that grounds the coach). The profile text is intentionally omitted —
|
||||||
|
// only its presence, source path, and size cross the wire.
|
||||||
|
type KnowledgeView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Chars int `json:"chars,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReflectionView projects the reviewer's output. Recap is shown on Review;
|
||||||
|
// CarryForward is shown on the next Planning screen. Unlike the knowledge
|
||||||
|
// profile, these short lines exist to be displayed, so they cross the wire.
|
||||||
|
type ReflectionView struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Recap string `json:"recap,omitempty"`
|
||||||
|
CarryForward string `json:"carry_forward,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowView / BucketView / EvidenceView are the evidence projection.
|
||||||
|
type WindowView struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BucketView struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EvidenceView struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Current WindowView `json:"current"`
|
||||||
|
SwitchCount int `json:"switch_count"`
|
||||||
|
Buckets []BucketView `json:"buckets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the broadcastable view of the controller.
|
||||||
|
type State struct {
|
||||||
|
RuntimeState domain.RuntimeState `json:"runtime_state"`
|
||||||
|
Commitment *CommitmentView `json:"commitment"`
|
||||||
|
Evidence *EvidenceView `json:"evidence"`
|
||||||
|
Coach *CoachView `json:"coach,omitempty"`
|
||||||
|
Tasks *TasksView `json:"tasks,omitempty"`
|
||||||
|
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
||||||
|
Reflection *ReflectionView `json:"reflection,omitempty"`
|
||||||
|
Drift *DriftView `json:"drift,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Mode) stateLocked() State {
|
||||||
|
st := State{RuntimeState: c.runtimeState}
|
||||||
|
if c.commitment != nil {
|
||||||
|
view := &CommitmentView{
|
||||||
|
NextAction: c.commitment.NextAction,
|
||||||
|
SuccessCondition: c.commitment.SuccessCondition,
|
||||||
|
TimeboxSecs: c.commitment.TimeboxSecs,
|
||||||
|
}
|
||||||
|
if !c.deadline.IsZero() {
|
||||||
|
view.DeadlineUnixSecs = c.deadline.Unix()
|
||||||
|
}
|
||||||
|
st.Commitment = view
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
st.Evidence = &EvidenceView{
|
||||||
|
Available: c.stats.Current.Health.Available,
|
||||||
|
Reason: c.stats.Current.Health.Reason,
|
||||||
|
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
|
||||||
|
SwitchCount: c.stats.SwitchCount,
|
||||||
|
Buckets: bucketViews(c.stats.Buckets),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if status == "" {
|
||||||
|
status = coachIdle
|
||||||
|
}
|
||||||
|
cv := &CoachView{Status: status, Error: c.coachErr}
|
||||||
|
if c.coachProposal != nil {
|
||||||
|
cv.Proposal = &ProposalView{
|
||||||
|
NextAction: c.coachProposal.NextAction,
|
||||||
|
SuccessCondition: c.coachProposal.SuccessCondition,
|
||||||
|
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
||||||
|
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Coach = cv
|
||||||
|
if c.tasksProvider != nil {
|
||||||
|
tstatus := c.tasksStatus
|
||||||
|
if tstatus == "" {
|
||||||
|
tstatus = tasksIdle
|
||||||
|
}
|
||||||
|
tv := &TasksView{Status: tstatus}
|
||||||
|
for _, t := range c.tasksList {
|
||||||
|
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
|
||||||
|
}
|
||||||
|
st.Tasks = tv
|
||||||
|
}
|
||||||
|
if c.knowledgeSrc != nil {
|
||||||
|
kstatus := c.knowledgeStatus
|
||||||
|
if kstatus == "" {
|
||||||
|
kstatus = knowledgeIdle
|
||||||
|
}
|
||||||
|
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
|
||||||
|
}
|
||||||
|
if c.carryForward != "" {
|
||||||
|
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.runtimeState == domain.RuntimeReview {
|
||||||
|
rstatus := c.reflectionStatus
|
||||||
|
if rstatus == "" {
|
||||||
|
rstatus = reflectionIdle
|
||||||
|
}
|
||||||
|
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
|
||||||
|
}
|
||||||
|
if c.runtimeState == domain.RuntimeActive {
|
||||||
|
status := c.driftStatus
|
||||||
|
if status == "" {
|
||||||
|
status = driftIdle
|
||||||
|
}
|
||||||
|
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
|
||||||
|
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
|
||||||
|
out := make([]BucketView, 0, len(buckets))
|
||||||
|
for k, d := range buckets {
|
||||||
|
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -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,547 +0,0 @@
|
|||||||
// Package session owns the daemon's in-memory state of truth and persists a
|
|
||||||
// snapshot on every change. Transitions go through the pure statemachine. It
|
|
||||||
// also owns per-session evidence stats: it accumulates active-window time while
|
|
||||||
// Active, logs raw focus events, and seals each session into the audit chain.
|
|
||||||
package session
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
|
||||||
"antidrift/internal/domain"
|
|
||||||
"antidrift/internal/evidence"
|
|
||||||
"antidrift/internal/statemachine"
|
|
||||||
"antidrift/internal/store"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
unavailableTitle = "(evidence unavailable)"
|
|
||||||
sessionRetention = 30 * 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
const coachTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
const (
|
|
||||||
coachIdle = "idle"
|
|
||||||
coachPending = "pending"
|
|
||||||
coachReady = "ready"
|
|
||||||
coachError = "error"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
|
|
||||||
|
|
||||||
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
|
||||||
type bucketKey struct{ Class, Title string }
|
|
||||||
|
|
||||||
// EvidenceStats is the in-memory accounting for the current session only.
|
|
||||||
type EvidenceStats struct {
|
|
||||||
SessionID string
|
|
||||||
StartedUnix int64
|
|
||||||
Buckets map[bucketKey]time.Duration
|
|
||||||
SwitchCount int
|
|
||||||
Current evidence.WindowSnapshot
|
|
||||||
lastFocusAt time.Time
|
|
||||||
lastKey bucketKey
|
|
||||||
hasLast bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controller holds runtime state and the active commitment behind a mutex.
|
|
||||||
type Controller struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
runtimeState domain.RuntimeState
|
|
||||||
commitment *domain.Commitment
|
|
||||||
deadline time.Time
|
|
||||||
snapshotPath string
|
|
||||||
auditPath string
|
|
||||||
sessionsDir string
|
|
||||||
clock func() time.Time
|
|
||||||
onChange func()
|
|
||||||
latestWindow evidence.WindowSnapshot
|
|
||||||
stats *EvidenceStats
|
|
||||||
outcomePending string
|
|
||||||
coach ai.Coach
|
|
||||||
coachStatus string
|
|
||||||
coachProposal *ai.Proposal
|
|
||||||
coachErr string
|
|
||||||
coachGen int
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommitmentView is the UI projection of the active commitment.
|
|
||||||
type CommitmentView struct {
|
|
||||||
NextAction string `json:"next_action"`
|
|
||||||
SuccessCondition string `json:"success_condition"`
|
|
||||||
TimeboxSecs int64 `json:"timebox_secs"`
|
|
||||||
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProposalView / CoachView project the ephemeral planning-coach state.
|
|
||||||
type ProposalView struct {
|
|
||||||
NextAction string `json:"next_action"`
|
|
||||||
SuccessCondition string `json:"success_condition"`
|
|
||||||
TimeboxSecs int64 `json:"timebox_secs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CoachView struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Proposal *ProposalView `json:"proposal,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WindowView / BucketView / EvidenceView are the evidence projection.
|
|
||||||
type WindowView struct {
|
|
||||||
Class string `json:"class"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BucketView struct {
|
|
||||||
Class string `json:"class"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Seconds int64 `json:"seconds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type EvidenceView struct {
|
|
||||||
Available bool `json:"available"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Current WindowView `json:"current"`
|
|
||||||
SwitchCount int `json:"switch_count"`
|
|
||||||
Buckets []BucketView `json:"buckets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// State is the broadcastable view of the controller.
|
|
||||||
type State struct {
|
|
||||||
RuntimeState domain.RuntimeState `json:"runtime_state"`
|
|
||||||
Commitment *CommitmentView `json:"commitment"`
|
|
||||||
Evidence *EvidenceView `json:"evidence"`
|
|
||||||
Coach *CoachView `json:"coach,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
|
||||||
// in-memory stats from the raw log if a live session was interrupted.
|
|
||||||
func New(snapshotPath string) (*Controller, error) {
|
|
||||||
s, err := store.Load(snapshotPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dir := filepath.Dir(snapshotPath)
|
|
||||||
c := &Controller{
|
|
||||||
runtimeState: s.RuntimeState,
|
|
||||||
commitment: s.Commitment,
|
|
||||||
snapshotPath: snapshotPath,
|
|
||||||
auditPath: filepath.Join(dir, "audit.jsonl"),
|
|
||||||
sessionsDir: filepath.Join(dir, "sessions"),
|
|
||||||
clock: time.Now,
|
|
||||||
outcomePending: s.OutcomePending,
|
|
||||||
}
|
|
||||||
if s.DeadlineUnixSecs > 0 {
|
|
||||||
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
|
|
||||||
}
|
|
||||||
if c.runtimeState == "" {
|
|
||||||
c.runtimeState = domain.RuntimeLocked
|
|
||||||
}
|
|
||||||
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
|
||||||
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
|
||||||
c.replayStats(s.SessionID)
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetClock overrides the time source (tests only). Call before starting a
|
|
||||||
// session.
|
|
||||||
func (c *Controller) SetClock(f func() time.Time) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.clock = f
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOnChange registers a callback fired after an evidence-driven state change
|
|
||||||
// (focus updates). It is invoked with the mutex released.
|
|
||||||
func (c *Controller) SetOnChange(f func()) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.onChange = f
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) notify() {
|
|
||||||
c.mu.Lock()
|
|
||||||
f := c.onChange
|
|
||||||
c.mu.Unlock()
|
|
||||||
if f != nil {
|
|
||||||
f()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// State returns the current broadcastable state. Safe for concurrent use.
|
|
||||||
func (c *Controller) State() State {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return c.stateLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deadline returns the active commitment deadline, or the zero time.
|
|
||||||
func (c *Controller) Deadline() time.Time {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return c.deadline
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) stateLocked() State {
|
|
||||||
st := State{RuntimeState: c.runtimeState}
|
|
||||||
if c.commitment != nil {
|
|
||||||
view := &CommitmentView{
|
|
||||||
NextAction: c.commitment.NextAction,
|
|
||||||
SuccessCondition: c.commitment.SuccessCondition,
|
|
||||||
TimeboxSecs: c.commitment.TimeboxSecs,
|
|
||||||
}
|
|
||||||
if !c.deadline.IsZero() {
|
|
||||||
view.DeadlineUnixSecs = c.deadline.Unix()
|
|
||||||
}
|
|
||||||
st.Commitment = view
|
|
||||||
}
|
|
||||||
if c.stats != nil {
|
|
||||||
st.Evidence = &EvidenceView{
|
|
||||||
Available: c.stats.Current.Health.Available,
|
|
||||||
Reason: c.stats.Current.Health.Reason,
|
|
||||||
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
|
|
||||||
SwitchCount: c.stats.SwitchCount,
|
|
||||||
Buckets: bucketViews(c.stats.Buckets),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.runtimeState == domain.RuntimePlanning {
|
|
||||||
status := c.coachStatus
|
|
||||||
if status == "" {
|
|
||||||
status = coachIdle
|
|
||||||
}
|
|
||||||
cv := &CoachView{Status: status, Error: c.coachErr}
|
|
||||||
if c.coachProposal != nil {
|
|
||||||
cv.Proposal = &ProposalView{
|
|
||||||
NextAction: c.coachProposal.NextAction,
|
|
||||||
SuccessCondition: c.coachProposal.SuccessCondition,
|
|
||||||
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.Coach = cv
|
|
||||||
}
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
|
|
||||||
out := make([]BucketView, 0, len(buckets))
|
|
||||||
for k, d := range buckets {
|
|
||||||
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) persistLocked() error {
|
|
||||||
snap := store.Snapshot{
|
|
||||||
RuntimeState: c.runtimeState,
|
|
||||||
Commitment: c.commitment,
|
|
||||||
OutcomePending: c.outcomePending,
|
|
||||||
}
|
|
||||||
if !c.deadline.IsZero() {
|
|
||||||
snap.DeadlineUnixSecs = c.deadline.Unix()
|
|
||||||
}
|
|
||||||
if c.stats != nil {
|
|
||||||
snap.SessionID = c.stats.SessionID
|
|
||||||
}
|
|
||||||
return store.Save(c.snapshotPath, snap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnterPlanning moves Locked -> Planning.
|
|
||||||
func (c *Controller) EnterPlanning() error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.runtimeState = next
|
|
||||||
c.resetCoachLocked()
|
|
||||||
return c.persistLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
|
||||||
// RequestCoach degrade gracefully.
|
|
||||||
func (c *Controller) SetCoach(coach ai.Coach) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.coach = coach
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetCoachLocked returns coach state to idle and invalidates any in-flight
|
|
||||||
// request. Caller holds mu.
|
|
||||||
func (c *Controller) resetCoachLocked() {
|
|
||||||
c.coachStatus = coachIdle
|
|
||||||
c.coachProposal = nil
|
|
||||||
c.coachErr = ""
|
|
||||||
c.coachGen++
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
|
||||||
// not in planning; otherwise never a hard error (failures surface as coach
|
|
||||||
// state). The proposal is ephemeral and never persisted.
|
|
||||||
func (c *Controller) RequestCoach(intent string) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
if c.runtimeState != domain.RuntimePlanning {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return ErrNotPlanning
|
|
||||||
}
|
|
||||||
if c.coach == nil {
|
|
||||||
c.coachStatus = coachError
|
|
||||||
c.coachErr = "coach unavailable"
|
|
||||||
c.coachProposal = nil
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.notify()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
c.coachGen++
|
|
||||||
gen := c.coachGen
|
|
||||||
c.coachStatus = coachPending
|
|
||||||
c.coachErr = ""
|
|
||||||
c.coachProposal = nil
|
|
||||||
coach := c.coach
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.notify()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
|
|
||||||
defer cancel()
|
|
||||||
prop, err := coach.Coach(ctx, intent)
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return // stale or left planning: discard
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
c.coachStatus = coachError
|
|
||||||
c.coachErr = coachErrorMessage(err)
|
|
||||||
c.coachProposal = nil
|
|
||||||
} else {
|
|
||||||
c.coachStatus = coachReady
|
|
||||||
c.coachProposal = &prop
|
|
||||||
c.coachErr = ""
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.notify()
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func coachErrorMessage(err error) string {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
|
|
||||||
return "coach returned an unusable response"
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
return "coach timed out"
|
|
||||||
default:
|
|
||||||
return "coach unavailable"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartManualCommitment validates input, activates a new commitment, mints a
|
|
||||||
// session, seeds evidence stats from the latest window, and moves Planning ->
|
|
||||||
// Active.
|
|
||||||
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
now := c.clock()
|
|
||||||
c.runtimeState = next
|
|
||||||
c.commitment = &commitment
|
|
||||||
c.deadline = now.Add(timebox)
|
|
||||||
c.outcomePending = ""
|
|
||||||
c.resetCoachLocked()
|
|
||||||
|
|
||||||
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
|
||||||
c.stats = &EvidenceStats{
|
|
||||||
SessionID: sessionID,
|
|
||||||
StartedUnix: now.Unix(),
|
|
||||||
Buckets: map[bucketKey]time.Duration{},
|
|
||||||
}
|
|
||||||
seed := c.latestWindow
|
|
||||||
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
|
|
||||||
c.applyEvent(now, seed)
|
|
||||||
return c.persistLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete moves Active -> Review with a "completed" outcome.
|
|
||||||
func (c *Controller) Complete() error { return c.enterReview("completed") }
|
|
||||||
|
|
||||||
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
|
|
||||||
func (c *Controller) Expire() error { return c.enterReview("expired") }
|
|
||||||
|
|
||||||
func (c *Controller) enterReview(outcome string) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if c.commitment != nil {
|
|
||||||
completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.commitment.State = completed
|
|
||||||
}
|
|
||||||
// Flush the final open segment, then freeze accounting.
|
|
||||||
if c.stats != nil && c.stats.hasLast {
|
|
||||||
c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt)
|
|
||||||
c.stats.hasLast = false
|
|
||||||
}
|
|
||||||
c.runtimeState = next
|
|
||||||
c.outcomePending = outcome
|
|
||||||
return c.persistLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// End moves Review -> Locked, writes the session summary to the audit chain,
|
|
||||||
// and clears the commitment and stats.
|
|
||||||
func (c *Controller) End() error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if c.stats != nil {
|
|
||||||
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
|
|
||||||
// State integrity over audit completeness: the transition still
|
|
||||||
// completes. Surfaced for the operator; no auto-retry in M1.
|
|
||||||
log.Printf("session: audit append failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.runtimeState = next
|
|
||||||
c.commitment = nil
|
|
||||||
c.deadline = time.Time{}
|
|
||||||
c.stats = nil
|
|
||||||
c.outcomePending = ""
|
|
||||||
return c.persistLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) buildSummaryLocked() store.SessionSummary {
|
|
||||||
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
|
|
||||||
for k, d := range c.stats.Buckets {
|
|
||||||
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
|
||||||
}
|
|
||||||
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds })
|
|
||||||
outcome := c.outcomePending
|
|
||||||
if outcome == "" {
|
|
||||||
outcome = "completed"
|
|
||||||
}
|
|
||||||
var na, sc string
|
|
||||||
if c.commitment != nil {
|
|
||||||
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
|
||||||
}
|
|
||||||
return store.SessionSummary{
|
|
||||||
SessionID: c.stats.SessionID,
|
|
||||||
NextAction: na,
|
|
||||||
SuccessCond: sc,
|
|
||||||
Outcome: outcome,
|
|
||||||
StartedUnix: c.stats.StartedUnix,
|
|
||||||
EndedUnix: c.clock().Unix(),
|
|
||||||
SwitchCount: c.stats.SwitchCount,
|
|
||||||
Buckets: buckets,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordWindow ingests one sensor observation. It accumulates time only while
|
|
||||||
// Active, always tracks the latest window for display, and fires onChange after
|
|
||||||
// releasing the mutex.
|
|
||||||
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.latestWindow = snap
|
|
||||||
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.notify()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := c.clock()
|
|
||||||
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
|
||||||
c.applyEvent(now, snap)
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyEvent advances stats by one observation: it credits the prior segment to
|
|
||||||
// the prior bucket, counts a context switch on key change, and records the new
|
|
||||||
// current window. Used by both live tracking and crash replay. Caller holds mu.
|
|
||||||
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
|
||||||
if c.stats.hasLast {
|
|
||||||
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
|
|
||||||
}
|
|
||||||
newKey := keyFor(snap)
|
|
||||||
if c.stats.hasLast && newKey != c.stats.lastKey {
|
|
||||||
c.stats.SwitchCount++
|
|
||||||
}
|
|
||||||
c.stats.lastKey = newKey
|
|
||||||
c.stats.lastFocusAt = now
|
|
||||||
c.stats.hasLast = true
|
|
||||||
c.stats.Current = snap
|
|
||||||
}
|
|
||||||
|
|
||||||
// replayStats rebuilds in-memory stats from the raw session log after a crash.
|
|
||||||
func (c *Controller) replayStats(sessionID string) {
|
|
||||||
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
|
||||||
if err != nil || len(events) == 0 {
|
|
||||||
c.stats = &EvidenceStats{
|
|
||||||
SessionID: sessionID,
|
|
||||||
StartedUnix: c.clock().Unix(),
|
|
||||||
Buckets: map[bucketKey]time.Duration{},
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.stats = &EvidenceStats{
|
|
||||||
SessionID: sessionID,
|
|
||||||
StartedUnix: events[0].AtUnixMillis / 1000,
|
|
||||||
Buckets: map[bucketKey]time.Duration{},
|
|
||||||
}
|
|
||||||
for _, e := range events {
|
|
||||||
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyFor(snap evidence.WindowSnapshot) bucketKey {
|
|
||||||
if !snap.Health.Available {
|
|
||||||
return bucketKey{Class: "", Title: unavailableTitle}
|
|
||||||
}
|
|
||||||
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
|
|
||||||
return store.FocusEvent{
|
|
||||||
AtUnixMillis: now.UnixMilli(),
|
|
||||||
Class: snap.Class,
|
|
||||||
Title: snap.Title,
|
|
||||||
Available: snap.Health.Available,
|
|
||||||
Reason: snap.Health.Reason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
|
|
||||||
return evidence.WindowSnapshot{
|
|
||||||
Title: e.Title,
|
|
||||||
Class: e.Class,
|
|
||||||
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
package session
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
|
||||||
"antidrift/internal/domain"
|
|
||||||
"antidrift/internal/evidence"
|
|
||||||
"antidrift/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestController(t *testing.T) (*Controller, string) {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
|
||||||
c, err := New(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new controller: %v", err)
|
|
||||||
}
|
|
||||||
return c, path
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHappyPathDrivesStates(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
if c.State().RuntimeState != domain.RuntimeLocked {
|
|
||||||
t.Fatalf("should start Locked")
|
|
||||||
}
|
|
||||||
if err := c.EnterPlanning(); err != nil {
|
|
||||||
t.Fatalf("enter planning: %v", err)
|
|
||||||
}
|
|
||||||
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil {
|
|
||||||
t.Fatalf("start commitment: %v", err)
|
|
||||||
}
|
|
||||||
st := c.State()
|
|
||||||
if st.RuntimeState != domain.RuntimeActive {
|
|
||||||
t.Fatalf("should be Active, got %s", st.RuntimeState)
|
|
||||||
}
|
|
||||||
if st.Commitment == nil || st.Commitment.NextAction != "Port session" {
|
|
||||||
t.Fatalf("active commitment missing: %+v", st.Commitment)
|
|
||||||
}
|
|
||||||
if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() {
|
|
||||||
t.Fatalf("deadline should be in the future")
|
|
||||||
}
|
|
||||||
if err := c.Complete(); err != nil {
|
|
||||||
t.Fatalf("complete: %v", err)
|
|
||||||
}
|
|
||||||
if c.State().RuntimeState != domain.RuntimeReview {
|
|
||||||
t.Fatalf("should be Review after complete")
|
|
||||||
}
|
|
||||||
if err := c.End(); err != nil {
|
|
||||||
t.Fatalf("end: %v", err)
|
|
||||||
}
|
|
||||||
if c.State().RuntimeState != domain.RuntimeLocked {
|
|
||||||
t.Fatalf("should be Locked after end")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil {
|
|
||||||
t.Fatalf("empty next action should error")
|
|
||||||
}
|
|
||||||
if c.State().RuntimeState != domain.RuntimePlanning {
|
|
||||||
t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateRestoresFromSnapshot(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
|
||||||
first, err := New(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new: %v", err)
|
|
||||||
}
|
|
||||||
_ = first.EnterPlanning()
|
|
||||||
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute)
|
|
||||||
|
|
||||||
second, err := New(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reopen: %v", err)
|
|
||||||
}
|
|
||||||
st := second.State()
|
|
||||||
if st.RuntimeState != domain.RuntimeActive {
|
|
||||||
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
|
|
||||||
}
|
|
||||||
if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" {
|
|
||||||
t.Fatalf("restored commitment missing: %+v", st.Commitment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
|
||||||
first, err := New(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new: %v", err)
|
|
||||||
}
|
|
||||||
_ = first.EnterPlanning()
|
|
||||||
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute)
|
|
||||||
want := first.State().Commitment.DeadlineUnixSecs
|
|
||||||
|
|
||||||
second, err := New(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reopen: %v", err)
|
|
||||||
}
|
|
||||||
got := second.State().Commitment
|
|
||||||
if got == nil || got.DeadlineUnixSecs != want {
|
|
||||||
t.Fatalf("restored deadline: got %+v want %d", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeClock returns successive instants on demand.
|
|
||||||
type fakeClock struct{ now time.Time }
|
|
||||||
|
|
||||||
func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) }
|
|
||||||
func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } }
|
|
||||||
|
|
||||||
func snap(class, title string) evidence.WindowSnapshot {
|
|
||||||
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bucketSeconds(t *testing.T, st State, class, title string) int64 {
|
|
||||||
t.Helper()
|
|
||||||
if st.Evidence == nil {
|
|
||||||
t.Fatalf("expected evidence projection")
|
|
||||||
}
|
|
||||||
for _, b := range st.Evidence.Buckets {
|
|
||||||
if b.Class == class && b.Title == title {
|
|
||||||
return b.Seconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAccumulatesBucketsAndSwitches(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
||||||
c.SetClock(clk.fn())
|
|
||||||
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
|
||||||
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift
|
|
||||||
|
|
||||||
clk.advance(10 * time.Second)
|
|
||||||
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
|
||||||
clk.advance(20 * time.Second)
|
|
||||||
c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back
|
|
||||||
|
|
||||||
st := c.State()
|
|
||||||
if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 {
|
|
||||||
t.Errorf("code bucket = %d, want 10", got)
|
|
||||||
}
|
|
||||||
if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 {
|
|
||||||
t.Errorf("firefox bucket = %d, want 20", got)
|
|
||||||
}
|
|
||||||
if st.Evidence.SwitchCount != 2 {
|
|
||||||
t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoAccountingOutsideActive(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
||||||
c.SetClock(clk.fn())
|
|
||||||
// Locked: RecordWindow must not create stats.
|
|
||||||
c.RecordWindow(snap("code", "x"))
|
|
||||||
if c.State().Evidence != nil {
|
|
||||||
t.Fatalf("no session: evidence should be nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnavailableAccruesToReservedBucket(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
||||||
c.SetClock(clk.fn())
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow
|
|
||||||
// latestWindow was zero-value (unavailable) at seed time.
|
|
||||||
clk.advance(5 * time.Second)
|
|
||||||
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
|
||||||
st := c.State()
|
|
||||||
if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 {
|
|
||||||
t.Errorf("unavailable bucket = %d, want 5", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrashReplayRebuildsStats(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
|
||||||
first, _ := New(path)
|
|
||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
||||||
first.SetClock(clk.fn())
|
|
||||||
first.RecordWindow(snap("code", "antidrift"))
|
|
||||||
_ = first.EnterPlanning()
|
|
||||||
_ = first.StartManualCommitment("work", "done", 25*time.Minute)
|
|
||||||
clk.advance(15 * time.Second)
|
|
||||||
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
|
||||||
|
|
||||||
// Simulate crash + restart: New replays the raw log.
|
|
||||||
second, err := New(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reopen: %v", err)
|
|
||||||
}
|
|
||||||
st := second.State()
|
|
||||||
if st.RuntimeState != domain.RuntimeActive {
|
|
||||||
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
|
|
||||||
}
|
|
||||||
if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 {
|
|
||||||
t.Errorf("replayed code bucket = %d, want 15", got)
|
|
||||||
}
|
|
||||||
if st.Evidence.SwitchCount != 1 {
|
|
||||||
t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEndWritesAuditSummary(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "state.json")
|
|
||||||
c, _ := New(path)
|
|
||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
||||||
c.SetClock(clk.fn())
|
|
||||||
c.RecordWindow(snap("code", "antidrift"))
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute)
|
|
||||||
clk.advance(30 * time.Second)
|
|
||||||
c.RecordWindow(snap("firefox", "docs"))
|
|
||||||
clk.advance(10 * time.Second)
|
|
||||||
if err := c.Complete(); err != nil { // flush: +10s to firefox/docs
|
|
||||||
t.Fatalf("complete: %v", err)
|
|
||||||
}
|
|
||||||
if err := c.End(); err != nil {
|
|
||||||
t.Fatalf("end: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auditPath := filepath.Join(dir, "audit.jsonl")
|
|
||||||
if err := store.VerifyChain(auditPath); err != nil {
|
|
||||||
t.Fatalf("chain should verify: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeCoach struct {
|
|
||||||
prop ai.Proposal
|
|
||||||
err error
|
|
||||||
gate chan struct{} // if non-nil, Coach blocks until it receives
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
|
||||||
if f.gate != nil {
|
|
||||||
<-f.gate
|
|
||||||
}
|
|
||||||
return f.prop, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
|
||||||
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
st := c.State()
|
|
||||||
if st.Coach != nil && st.Coach.Status == want {
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach)
|
|
||||||
return State{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestCoachReady(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}})
|
|
||||||
if err := c.RequestCoach("do x"); err != nil {
|
|
||||||
t.Fatalf("request coach: %v", err)
|
|
||||||
}
|
|
||||||
st := waitCoachStatus(t, c, "ready")
|
|
||||||
if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 {
|
|
||||||
t.Fatalf("ready proposal wrong: %+v", st.Coach)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestCoachError(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
c.SetCoach(&fakeCoach{err: errors.New("backend down")})
|
|
||||||
if err := c.RequestCoach("x"); err != nil {
|
|
||||||
t.Fatalf("request: %v", err)
|
|
||||||
}
|
|
||||||
st := waitCoachStatus(t, c, "error")
|
|
||||||
if st.Coach.Error == "" {
|
|
||||||
t.Fatal("error status should carry a message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestCoachUnavailable(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
if err := c.RequestCoach("x"); err != nil {
|
|
||||||
t.Fatalf("nil coach must degrade, not error: %v", err)
|
|
||||||
}
|
|
||||||
st := c.State()
|
|
||||||
if st.Coach == nil || st.Coach.Status != "error" {
|
|
||||||
t.Fatalf("want error status for nil coach, got %+v", st.Coach)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestCoachWrongState(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
|
||||||
if err := c.RequestCoach("x"); err == nil {
|
|
||||||
t.Fatal("coaching from Locked should error")
|
|
||||||
}
|
|
||||||
if c.State().Coach != nil {
|
|
||||||
t.Fatal("no coach view outside planning")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestCoachStaleResultDiscarded(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
|
|
||||||
slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})}
|
|
||||||
c.SetCoach(slow)
|
|
||||||
_ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate
|
|
||||||
|
|
||||||
fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}}
|
|
||||||
c.SetCoach(fast)
|
|
||||||
_ = c.RequestCoach("second") // gen2 -> ready NEW
|
|
||||||
st := waitCoachStatus(t, c, "ready")
|
|
||||||
if st.Coach.Proposal.NextAction != "NEW" {
|
|
||||||
t.Fatalf("expected NEW, got %+v", st.Coach.Proposal)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(slow.gate) // release gen1; it must be discarded
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if got := c.State().Coach.Proposal.NextAction; got != "NEW" {
|
|
||||||
t.Fatalf("stale gen1 overwrote state: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
_ = c.EnterPlanning()
|
|
||||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
|
||||||
_ = c.RequestCoach("x")
|
|
||||||
waitCoachStatus(t, c, "ready")
|
|
||||||
if err := c.StartManualCommitment("a", "b", 25*time.Minute); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
if c.State().Coach != nil {
|
|
||||||
t.Fatal("coach view must be gone once Active")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Package settings persists the daemon's user-editable configuration as a single
|
||||||
|
// JSON file (~/.keel/settings.json), parallel to the store snapshot. It is a
|
||||||
|
// leaf type package: it imports nothing else in the app so any layer may depend
|
||||||
|
// on the Settings value without pulling in adapters.
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
||||||
|
// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400
|
||||||
|
// without importing the ai package.
|
||||||
|
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
||||||
|
|
||||||
|
// ErrInvalidAmbientMode marks a settings value whose ambient_mode is not a
|
||||||
|
// known mode. The applier returns it (wrapped) so the HTTP layer can map it to
|
||||||
|
// 400 without importing the ambient package.
|
||||||
|
var ErrInvalidAmbientMode = errors.New("settings: invalid ambient mode")
|
||||||
|
|
||||||
|
// Ambient mode dial values.
|
||||||
|
const (
|
||||||
|
AmbientOff = "off"
|
||||||
|
AmbientStatus = "status"
|
||||||
|
AmbientNotify = "notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidAmbientMode reports whether m is a known ambient mode constant.
|
||||||
|
func ValidAmbientMode(m string) bool {
|
||||||
|
switch m {
|
||||||
|
case AmbientOff, AmbientStatus, AmbientNotify:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings is the user-editable configuration. Field names mirror the env vars
|
||||||
|
// they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE, KEEL_AW_URL,
|
||||||
|
// KEEL_AMBIENT_MODE, KEEL_AMBIENT_CADENCE_SECS.
|
||||||
|
type Settings struct {
|
||||||
|
AIBackend string `json:"ai_backend"`
|
||||||
|
MarvinCmd string `json:"marvin_cmd"`
|
||||||
|
KnowledgePath string `json:"knowledge_path"`
|
||||||
|
AWURL string `json:"aw_url"`
|
||||||
|
AmbientMode string `json:"ambient_mode"`
|
||||||
|
AmbientCadenceSecs int `json:"ambient_cadence_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPath returns ~/.keel/settings.json.
|
||||||
|
func DefaultPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".keel", "settings.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads a settings file. A missing file is an error; callers treat that as
|
||||||
|
// "first run" and seed instead.
|
||||||
|
func Load(path string) (Settings, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
var s Settings
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes settings atomically (temp file + rename), creating the directory
|
||||||
|
// if needed. Mirrors store.Save.
|
||||||
|
func Save(path string, s Settings) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedFromEnv builds a Settings from the KEEL_* env vars, applying
|
||||||
|
// the same defaults the daemon used before the settings file existed. An unset
|
||||||
|
// AI backend defaults to "claude" (matching ai.NewBackend("")).
|
||||||
|
func SeedFromEnv() Settings {
|
||||||
|
backend := os.Getenv("KEEL_AI_BACKEND")
|
||||||
|
if backend == "" {
|
||||||
|
backend = "claude"
|
||||||
|
}
|
||||||
|
awURL := os.Getenv("KEEL_AW_URL")
|
||||||
|
if awURL == "" {
|
||||||
|
awURL = "http://localhost:5600"
|
||||||
|
}
|
||||||
|
ambientMode := os.Getenv("KEEL_AMBIENT_MODE")
|
||||||
|
if ambientMode == "" {
|
||||||
|
ambientMode = AmbientNotify
|
||||||
|
}
|
||||||
|
ambientCadenceSecs := 300
|
||||||
|
if v := os.Getenv("KEEL_AMBIENT_CADENCE_SECS"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
ambientCadenceSecs = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Settings{
|
||||||
|
AIBackend: backend,
|
||||||
|
MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
|
||||||
|
KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
|
||||||
|
AWURL: awURL,
|
||||||
|
AmbientMode: ambientMode,
|
||||||
|
AmbientCadenceSecs: ambientCadenceSecs,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600"}
|
||||||
|
if err := Save(path, want); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("round trip = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMissingFileIsError(t *testing.T) {
|
||||||
|
_, err := Load(filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error loading missing file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvReadsVars(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AI_BACKEND", "codex")
|
||||||
|
t.Setenv("KEEL_MARVIN_CMD", "uv run am")
|
||||||
|
t.Setenv("KEEL_KNOWLEDGE_FILE", "/tmp/k.md")
|
||||||
|
t.Setenv("KEEL_AW_URL", "http://aw.local:5600")
|
||||||
|
t.Setenv("KEEL_AMBIENT_MODE", "")
|
||||||
|
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
|
||||||
|
got := SeedFromEnv()
|
||||||
|
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600", AmbientMode: AmbientNotify, AmbientCadenceSecs: 300}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedFromEnvDefaultsBackend(t *testing.T) {
|
||||||
|
t.Setenv("KEEL_AI_BACKEND", "")
|
||||||
|
t.Setenv("KEEL_MARVIN_CMD", "")
|
||||||
|
t.Setenv("KEEL_KNOWLEDGE_FILE", "")
|
||||||
|
got := SeedFromEnv()
|
||||||
|
if got.AIBackend != "claude" {
|
||||||
|
t.Errorf("default backend = %q, want claude", got.AIBackend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveCreatesDir(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "sub", "settings.json")
|
||||||
|
if err := Save(path, Settings{AIBackend: "claude"}); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// Package statusfile mirrors the harness's runtime status into a single-line
|
||||||
|
// file (~/.keel_status) so an external consumer — a window-manager status
|
||||||
|
// bar — can display it with a plain file read.
|
||||||
|
package statusfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/mode"
|
||||||
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statusFileName is the file written under the user's home directory.
|
||||||
|
const statusFileName = ".keel_status"
|
||||||
|
|
||||||
|
// interval is how often the writer re-renders. Minute granularity is enough for
|
||||||
|
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
|
||||||
|
const interval = time.Minute
|
||||||
|
|
||||||
|
// DefaultPath resolves ~/.keel_status.
|
||||||
|
func DefaultPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, statusFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render produces the single status line for the harness envelope, dispatching
|
||||||
|
// on the active mode. An idle harness ("") renders the ambient coaching line as
|
||||||
|
// "⚠ <line>" when one is present, else "idle"; an unknown kind renders a generic
|
||||||
|
// fallback so a future mode degrades gracefully.
|
||||||
|
func Render(env mode.Envelope, ambientLine string, now time.Time) string {
|
||||||
|
switch env.ActiveMode {
|
||||||
|
case "":
|
||||||
|
if line := strings.TrimSpace(ambientLine); line != "" {
|
||||||
|
return "⚠ " + line
|
||||||
|
}
|
||||||
|
return "idle"
|
||||||
|
case "focus":
|
||||||
|
st, ok := env.Mode.(focus.State)
|
||||||
|
if !ok {
|
||||||
|
// Defensive: the envelope claims focus but carries an unexpected
|
||||||
|
// payload. Render a safe fallback instead of panicking on the assert.
|
||||||
|
return "focus"
|
||||||
|
}
|
||||||
|
return renderFocus(st, now)
|
||||||
|
case "offscreen":
|
||||||
|
return renderOffscreen(env.Mode)
|
||||||
|
default:
|
||||||
|
return env.ActiveMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOffscreen produces the off-screen status line from the mode's untyped
|
||||||
|
// View payload (a map[string]any read from the live in-process envelope). It
|
||||||
|
// shows the proposed next action when one is present, a warning glyph on error,
|
||||||
|
// and a "thinking…" placeholder otherwise. Any payload mismatch degrades to the
|
||||||
|
// placeholder rather than panicking.
|
||||||
|
func renderOffscreen(m any) string {
|
||||||
|
const thinking = "off-screen: thinking…"
|
||||||
|
view, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return thinking
|
||||||
|
}
|
||||||
|
status, _ := view["status"].(string)
|
||||||
|
switch status {
|
||||||
|
case "proposed":
|
||||||
|
na, _ := view["next_action"].(string)
|
||||||
|
if na = strings.TrimSpace(na); na != "" {
|
||||||
|
return "off-screen: " + na
|
||||||
|
}
|
||||||
|
return thinking
|
||||||
|
case "error":
|
||||||
|
return "off-screen: ⚠"
|
||||||
|
default:
|
||||||
|
return thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFocus produces the focus status line. It is empty for Locked/idle focus
|
||||||
|
// states so a bar module can hide itself. Drift status strings ("drifting")
|
||||||
|
// match the JSON contract the web UI already consumes.
|
||||||
|
func renderFocus(st focus.State, now time.Time) string {
|
||||||
|
switch st.RuntimeState {
|
||||||
|
case domain.RuntimeActive:
|
||||||
|
timer := remaining(st, now)
|
||||||
|
switch {
|
||||||
|
case st.Drift != nil && st.Drift.Status == "drifting":
|
||||||
|
return "⚠ DRIFT " + timer
|
||||||
|
case st.Drift != nil && st.Drift.Nudge != "":
|
||||||
|
return "● " + timer + " ·?"
|
||||||
|
default:
|
||||||
|
return "● " + timer
|
||||||
|
}
|
||||||
|
case domain.RuntimePlanning:
|
||||||
|
return "◔ planning"
|
||||||
|
case domain.RuntimeReview:
|
||||||
|
return "✓ review"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remaining formats the minutes left until the commitment deadline, rounded up
|
||||||
|
// so a partial minute still reads as a minute and the count hits 0m only at the
|
||||||
|
// end. It returns 0m when no deadline is known.
|
||||||
|
func remaining(st focus.State, now time.Time) string {
|
||||||
|
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
|
||||||
|
return "0m"
|
||||||
|
}
|
||||||
|
left := time.Unix(st.Commitment.DeadlineUnixSecs, 0).Sub(now)
|
||||||
|
if left < 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
mins := int((left + time.Minute - 1) / time.Minute) // ceil
|
||||||
|
return fmt.Sprintf("%dm", mins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer periodically renders the harness envelope (plus the ambient line) and
|
||||||
|
// writes it to a file, rewriting only when the line changes.
|
||||||
|
type Writer struct {
|
||||||
|
path string
|
||||||
|
state func() mode.Envelope
|
||||||
|
ambient func() string
|
||||||
|
now func() time.Time
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
|
last string
|
||||||
|
wrote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWriter builds a Writer for path, reading the harness envelope via state and
|
||||||
|
// the ambient coaching line via ambient (either may be supplied; a nil ambient
|
||||||
|
// is treated as "" by write).
|
||||||
|
func NewWriter(path string, state func() mode.Envelope, ambient func() string) *Writer {
|
||||||
|
return &Writer{path: path, state: state, ambient: ambient, now: time.Now, wake: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake asks the writer to re-render now rather than at the next tick. It is the
|
||||||
|
// hook the harness's change notifications fire through, so drift transitions
|
||||||
|
// reach the status bar promptly instead of lagging the web UI by up to a tick.
|
||||||
|
// The signal is coalesced (buffered, size 1): a burst of changes collapses into
|
||||||
|
// a single re-render, and the actual write still happens on the Run goroutine,
|
||||||
|
// 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) {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
w.write()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = os.Remove(w.path)
|
||||||
|
return
|
||||||
|
case <-w.wake:
|
||||||
|
w.write()
|
||||||
|
case <-t.C:
|
||||||
|
w.write()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Writer) write() {
|
||||||
|
line := ""
|
||||||
|
if w.ambient != nil {
|
||||||
|
line = w.ambient()
|
||||||
|
}
|
||||||
|
rendered := Render(w.state(), line, w.now())
|
||||||
|
if w.wrote && rendered == w.last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(w.path, []byte(rendered), 0o644); err != nil {
|
||||||
|
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.last = rendered
|
||||||
|
w.wrote = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package statusfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/mode"
|
||||||
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// focusEnv wraps a focus.State in the harness envelope the writer now consumes.
|
||||||
|
func focusEnv(st focus.State) mode.Envelope {
|
||||||
|
return mode.Envelope{ActiveMode: "focus", Mode: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusState builds a focus.State with the given runtime state.
|
||||||
|
func focusState(rt domain.RuntimeState) focus.State {
|
||||||
|
return focus.State{RuntimeState: rt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeState(deadline int64, driftStatus, nudge string) focus.State {
|
||||||
|
st := focus.State{
|
||||||
|
RuntimeState: domain.RuntimeActive,
|
||||||
|
Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline},
|
||||||
|
}
|
||||||
|
if driftStatus != "" || nudge != "" {
|
||||||
|
st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender(t *testing.T) {
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
in24m := now.Add(24 * time.Minute).Unix()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
env mode.Envelope
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"idle", mode.Envelope{}, "idle"},
|
||||||
|
{"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""},
|
||||||
|
{"empty focus runtime", focusEnv(focus.State{}), ""},
|
||||||
|
{"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"},
|
||||||
|
{"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"},
|
||||||
|
{"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"},
|
||||||
|
{"active no drift view", focusEnv(activeState(in24m, "", "")), "● 24m"},
|
||||||
|
{"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"},
|
||||||
|
{"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), "● 24m ·?"},
|
||||||
|
{"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"},
|
||||||
|
{"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"},
|
||||||
|
{"active past deadline", focusEnv(activeState(now.Add(-5*time.Minute).Unix(), "ontask", "")), "● 0m"},
|
||||||
|
{"active partial minute rounds up", focusEnv(activeState(now.Add(10*time.Second).Unix(), "ontask", "")), "● 1m"},
|
||||||
|
{"unknown kind falls back to kind name", mode.Envelope{ActiveMode: "house"}, "house"},
|
||||||
|
{"focus with wrong payload falls back", mode.Envelope{ActiveMode: "focus", Mode: "bogus"}, "focus"},
|
||||||
|
{"offscreen proposed", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "call mum"}}, "off-screen: call mum"},
|
||||||
|
{"offscreen pending", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "pending"}}, "off-screen: thinking…"},
|
||||||
|
{"offscreen error", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "error", "error": "boom"}}, "off-screen: ⚠"},
|
||||||
|
{"offscreen proposed no action", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed"}}, "off-screen: thinking…"},
|
||||||
|
{"offscreen wrong payload", mode.Envelope{ActiveMode: "offscreen", Mode: "bogus"}, "off-screen: thinking…"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Render(tc.env, "", now); got != tc.want {
|
||||||
|
t.Errorf("Render() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterWritesAndDedups(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
env := focusEnv(focusState(domain.RuntimePlanning))
|
||||||
|
w := NewWriter(path, func() mode.Envelope { return env }, nil)
|
||||||
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "◔ planning" {
|
||||||
|
t.Fatalf("first write = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same state: file untouched. Detect by clearing it and confirming the
|
||||||
|
// deduped write does not recreate content.
|
||||||
|
if err := os.WriteFile(path, []byte("sentinel"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "sentinel" {
|
||||||
|
t.Errorf("deduped write should not rewrite, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed state: rewrites.
|
||||||
|
env = focusEnv(focusState(domain.RuntimeLocked))
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "" {
|
||||||
|
t.Errorf("changed write = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterRemovesFileOnCancel(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
w := NewWriter(path, func() mode.Envelope {
|
||||||
|
return focusEnv(focusState(domain.RuntimePlanning))
|
||||||
|
}, nil)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { w.Run(ctx); close(done) }()
|
||||||
|
|
||||||
|
// Wait for the immediate write.
|
||||||
|
waitFor(t, func() bool { _, err := os.Stat(path); return err == nil })
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file should be removed on cancel, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, cond func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cond() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("condition not met within timeout")
|
||||||
|
}
|
||||||
@@ -74,6 +74,25 @@ func readSummaries(path string) ([]SessionSummary, error) {
|
|||||||
return out, sc.Err()
|
return out, sc.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentSessions returns up to n most-recent summaries from the audit chain in
|
||||||
|
// oldest-first order. A missing/empty chain or n <= 0 yields (nil, nil).
|
||||||
|
func RecentSessions(path string, n int) ([]SessionSummary, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
all, err := readSummaries(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(all) > n {
|
||||||
|
all = all[len(all)-n:]
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
|
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
|
||||||
// the next contiguous Seq, computes its Hash, and appends one JSON line with an
|
// the next contiguous Seq, computes its Hash, and appends one JSON line with an
|
||||||
// fsync.
|
// fsync.
|
||||||
|
|||||||
@@ -75,3 +75,45 @@ func TestTamperDetected(t *testing.T) {
|
|||||||
t.Fatalf("tamper on line 2 should be reported, got %v", err)
|
t.Fatalf("tamper on line 2 should be reported, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecentSessionsReturnsLastNOldestFirst(t *testing.T) {
|
||||||
|
path := auditFixture(t)
|
||||||
|
for _, id := range []string{"a", "b", "c"} {
|
||||||
|
if err := AppendSession(path, sampleSummary("session-"+id)); err != nil {
|
||||||
|
t.Fatalf("append %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := RecentSessions(path, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 summaries, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].SessionID != "session-b" || got[1].SessionID != "session-c" {
|
||||||
|
t.Fatalf("want [b c] oldest-first, got [%s %s]", got[0].SessionID, got[1].SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentSessionsFewerThanN(t *testing.T) {
|
||||||
|
path := auditFixture(t)
|
||||||
|
_ = AppendSession(path, sampleSummary("session-a"))
|
||||||
|
got, err := RecentSessions(path, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].SessionID != "session-a" {
|
||||||
|
t.Fatalf("want [a], got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentSessionsMissingOrZero(t *testing.T) {
|
||||||
|
if got, err := RecentSessions(auditFixture(t), 5); err != nil || got != nil {
|
||||||
|
t.Fatalf("missing chain: want (nil,nil), got (%+v,%v)", got, err)
|
||||||
|
}
|
||||||
|
path := auditFixture(t)
|
||||||
|
_ = AppendSession(path, sampleSummary("session-a"))
|
||||||
|
if got, err := RecentSessions(path, 0); err != nil || got != nil {
|
||||||
|
t.Fatalf("n=0: want (nil,nil), got (%+v,%v)", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user