Compare commits
97 Commits
5342da2755
...
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 |
+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,9 +49,26 @@ 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
|
M6 (knowledge port): the planning coach now sharpens intents against who you
|
||||||
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable
|
actually are. A single profile file (`~/.keel/knowledge.md`, overridable
|
||||||
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
via `KEEL_KNOWLEDGE_FILE`) holds your standing context — priorities,
|
||||||
values, what counts as good work — and the daemon loads it asynchronously on
|
values, what counts as good work — and the daemon loads it asynchronously on
|
||||||
entering planning, mirroring the tasks fetch. The cached text grounds the AI
|
entering planning, mirroring the tasks fetch. The cached text grounds the AI
|
||||||
coach prompt only; the drift judge and nudge are untouched, and the profile text
|
coach prompt only; the drift judge and nudge are untouched, and the profile text
|
||||||
@@ -53,12 +96,12 @@ The drift judge degrades gracefully — without it, local matching still runs.
|
|||||||
|
|
||||||
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
|
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
|
||||||
into a structured commitment (next action, success condition, timebox) by an
|
into a structured commitment (next action, success condition, timebox) by an
|
||||||
LLM CLI backend (claude or codex, selectable via `ANTIDRIFT_AI_BACKEND`). The
|
LLM CLI backend (claude or codex, selectable via `KEEL_AI_BACKEND`). The
|
||||||
coach runs asynchronously and degrades gracefully — manual planning always
|
coach runs asynchronously and degrades gracefully — manual planning always
|
||||||
works.
|
works.
|
||||||
|
|
||||||
M1 (evidence & audit): active-window tracking, two-tier evidence store
|
M1 (evidence & audit): active-window tracking, two-tier evidence store
|
||||||
(disposable per-session raw log + permanent hash-chained session summaries),
|
(disposable per-session raw log + permanent hash-chained session summaries),
|
||||||
and live SSE updates. Live drift judgment and the ambient nudge arrive in later
|
and live SSE updates. Live drift judgment and the ambient nudge arrived in later
|
||||||
milestones (see the roadmap in
|
milestones (M3 and M3.5 above; the original roadmap spec has since been removed —
|
||||||
`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`).
|
code and git history are the record).
|
||||||
|
|||||||
@@ -1,105 +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/knowledge"
|
|
||||||
"antidrift/internal/session"
|
|
||||||
"antidrift/internal/statusfile"
|
|
||||||
"antidrift/internal/store"
|
|
||||||
"antidrift/internal/tasks"
|
|
||||||
"antidrift/internal/web"
|
|
||||||
)
|
|
||||||
|
|
||||||
const addr = "localhost:7777"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
path, err := store.DefaultPath()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("resolve snapshot path: %v", err)
|
|
||||||
}
|
|
||||||
ctrl, err := session.New(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("load session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := web.NewServer(ctrl)
|
|
||||||
srv.Init() // re-arm or expire a restored Active session
|
|
||||||
|
|
||||||
// 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 disabled: %v", err)
|
|
||||||
} else {
|
|
||||||
svc := ai.NewService(backend)
|
|
||||||
ctrl.SetCoach(svc)
|
|
||||||
ctrl.SetDriftJudge(svc)
|
|
||||||
ctrl.SetNudge(svc)
|
|
||||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
|
|
||||||
// overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute
|
|
||||||
// path). A missing or failing CLI degrades to no tasks panel at fetch time —
|
|
||||||
// planning still works.
|
|
||||||
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
|
|
||||||
log.Printf("tasks: marvin adapter (am)")
|
|
||||||
|
|
||||||
// Wire the Knowledge port: a single profile file grounding the coach. The
|
|
||||||
// default path is overridable with ANTIDRIFT_KNOWLEDGE_FILE; unset falls back
|
|
||||||
// to ~/.antidrift/knowledge.md. A missing/unreadable file degrades to an
|
|
||||||
// ungrounded coach at load time — planning still works.
|
|
||||||
ctrl.SetKnowledge(knowledge.NewFileSource(os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE")))
|
|
||||||
log.Printf("knowledge: file adapter")
|
|
||||||
|
|
||||||
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
|
|
||||||
// A misconfigured home dir degrades to no status file, not a failed start.
|
|
||||||
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
|
||||||
log.Printf("status file disabled: %v", err)
|
|
||||||
} else {
|
|
||||||
go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background())
|
|
||||||
log.Printf("status: writing %s", statusPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
File diff suppressed because it is too large
Load Diff
@@ -1,876 +0,0 @@
|
|||||||
# M3.5 — Semantic Nudge 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:** Add a soft, ambient AI nudge that catches *semantic* drift within an allowed app ("right app, wrong work") — the case the M3 drift interceptor is structurally blind to.
|
|
||||||
|
|
||||||
**Architecture:** A third advisor role, `ai.Nudger`, on the existing `ai.Service`, taking primitives only (leaf package preserved). `session.Controller` keeps a ring of recent window titles and, *only on the local-match on-task path* (where the drift judge stays silent), runs a debounced async nudge using the exact M3 discipline: generation-guarded, goroutine launched after the mutex is released, never fabricates a concern on error. The advisory surfaces over SSE as a new `DriftView.Nudge` field and renders as a dismiss-only "Heads up" tier on the active-view banner.
|
|
||||||
|
|
||||||
**Tech Stack:** Go 1.26, Gin, SSE, stdlib `testing` only (no testify). `go test -race ./...` and `go vet ./...` must stay clean.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md`
|
|
||||||
|
|
||||||
**Model selection:** Task 1 (isolated parser) and Tasks 3–6 (mechanical/trivial) → fast model. Task 2 (controller concurrency) → most capable model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `internal/ai/nudge.go` | new: `Nudger` interface, `Service.Nudge`, `buildNudgePrompt`, `parseNudge`, `ErrInvalidNudge` |
|
|
||||||
| `internal/ai/nudge_test.go` | new: `parseNudge` table tests |
|
|
||||||
| `internal/session/session.go` | nudge fields + constants, `recentTitles` ring, `maybeNudgeLocked` + closure, `SetNudge`, `commitmentLineLocked`, `DriftView.Nudge`, `stateLocked` wiring, `resetDriftLocked` additions, `recentTitlesForTest` |
|
|
||||||
| `internal/session/session_test.go` | `fakeNudger`/`nudgerFunc` + nudge tests |
|
|
||||||
| `internal/web/web_test.go` | assert `nudge` field in serialized state |
|
|
||||||
| `internal/web/static/index.html` | "Heads up" soft tier in `updateActiveDrift` + CSS + client dismiss |
|
|
||||||
| `cmd/antidriftd/main.go` | `ctrl.SetNudge(svc)` + log line |
|
|
||||||
| `README.md` | M3.5 paragraph in Status |
|
|
||||||
|
|
||||||
`internal/web/web.go` needs **no** change: `DriftView` lives in `session` and serializes via its JSON tags automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: `ai.Nudger` role and response parsing
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/ai/nudge.go`
|
|
||||||
- Test: `internal/ai/nudge_test.go`
|
|
||||||
|
|
||||||
This task is self-contained in the `ai` leaf package. The `ai.Service` and its `Backend` already exist (`internal/ai/coach.go`): `Service` wraps a `Backend` and `Coach`/`JudgeDrift` are methods on it. `extractJSON`, `ErrEmptyResponse`, and `ErrNoJSON` are shared helpers defined in `internal/ai/proposal.go`. You are adding a third role, `Nudge`, that mirrors `JudgeDrift` (`internal/ai/coach.go:47-75`) and `parseVerdict` (`internal/ai/verdict.go`).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `internal/ai/nudge_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `go test ./internal/ai/ -run TestParseNudge`
|
|
||||||
Expected: build failure — `undefined: parseNudge` and `undefined: ErrInvalidNudge`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write the implementation**
|
|
||||||
|
|
||||||
Create `internal/ai/nudge.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 struct {
|
|
||||||
OnTrack bool `json:"on_track"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `go test ./internal/ai/ -run TestParseNudge -v`
|
|
||||||
Expected: PASS (all subtests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Vet and full ai-package test**
|
|
||||||
|
|
||||||
Run: `go vet ./internal/ai/ && go test ./internal/ai/`
|
|
||||||
Expected: no vet output; PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/ai/nudge.go internal/ai/nudge_test.go
|
|
||||||
git commit -m "Add ai.Nudger role for semantic drift within allowed apps
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Controller nudge state, ring, and orchestration
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/session/session.go`
|
|
||||||
- Test: `internal/session/session_test.go`
|
|
||||||
|
|
||||||
This is the concurrency-sensitive core. Study the existing drift orchestration first: the constants block (`internal/session/session.go:40-43`), the `Controller` struct fields (`:71-98`), `DriftView` (`:117-121`), `stateLocked`'s drift block (`:270-276`), `SetDriftJudge` (`:342-348`), `resetDriftLocked` (`:350-357`), `RecordWindow` (`:583-603`), and `evaluateDriftLocked` (`:605-679`). You will mirror that drift discipline exactly: a closure is built under the lock and returned to `RecordWindow`, which runs it via `go launch()` *after* unlocking; the closure re-acquires the lock, checks the generation guard, and calls `notify()` only after unlocking.
|
|
||||||
|
|
||||||
Key design facts:
|
|
||||||
- The drift judge and the nudge are **mutually exclusive per observation**: the drift judge runs only when the window is *unmatched*; the nudge runs only when the window is a *local match* (on-task). So `RecordWindow`'s single `launch func()` return is sufficient — no second goroutine handle is needed.
|
|
||||||
- The nudge reuses the existing `driftGen` counter as its session-identity guard (it is incremented only at session boundaries in `resetDriftLocked`), so a stale nudge is discarded exactly like a stale verdict.
|
|
||||||
- Nudge state (`recentTitles`, `nudgeMessage`, `lastNudgedAt`) is in-memory only and cleared in `resetDriftLocked`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Add to the imports of `internal/session/session_test.go` (it currently imports `context`, `errors`, `path/filepath`, `sync/atomic`, `testing`, `time`, and the internal packages): add `"fmt"`.
|
|
||||||
|
|
||||||
Append these test helpers and tests to `internal/session/session_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// fakeNudger is a controllable ai.Nudger for tests. An optional gate channel
|
|
||||||
// blocks the call until closed, mirroring fakeJudge.
|
|
||||||
type fakeNudger struct {
|
|
||||||
msg string
|
|
||||||
err error
|
|
||||||
gate chan struct{}
|
|
||||||
calls int32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
|
||||||
atomic.AddInt32(&f.calls, 1)
|
|
||||||
if f.gate != nil {
|
|
||||||
<-f.gate
|
|
||||||
}
|
|
||||||
return f.msg, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// nudgerFunc adapts a function to ai.Nudger for tests needing per-call results.
|
|
||||||
type nudgerFunc func(context.Context, string, []string) (string, error)
|
|
||||||
|
|
||||||
func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, error) {
|
|
||||||
return f(ctx, c, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitNudge(t *testing.T, c *Controller, want string) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
st := c.State()
|
|
||||||
if st.Drift != nil && st.Drift.Nudge == want {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("nudge never reached %q (last: %+v)", want, c.State().Drift)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNudgeFiresOnOnTaskPath(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
fn := &fakeNudger{msg: "drifted to an unrelated file"}
|
|
||||||
c.SetNudge(fn)
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
c.RecordWindow(obs("code", "auth.go")) // history 1 -> not yet eligible
|
|
||||||
c.RecordWindow(obs("code", "styles.css")) // history 2 -> eligible, launches
|
|
||||||
waitNudge(t, c, "drifted to an unrelated file")
|
|
||||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
|
||||||
t.Fatalf("expected exactly one nudge call, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
fn := &fakeNudger{msg: "should not run"}
|
|
||||||
c.SetNudge(fn)
|
|
||||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
c.RecordWindow(obs("firefox", "YouTube"))
|
|
||||||
c.RecordWindow(obs("firefox", "Reddit"))
|
|
||||||
waitDriftStatus(t, c, "drifting")
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
|
||||||
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNudgeDebounced(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
now := time.Now()
|
|
||||||
c.SetClock(func() time.Time { return now })
|
|
||||||
fn := &fakeNudger{msg: "drift"}
|
|
||||||
c.SetNudge(fn)
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
c.RecordWindow(obs("code", "a.go"))
|
|
||||||
c.RecordWindow(obs("code", "b.go")) // fires nudge #1
|
|
||||||
waitNudge(t, c, "drift")
|
|
||||||
c.RecordWindow(obs("code", "c.go")) // within debounce window -> suppressed
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
|
||||||
t.Fatalf("debounce should allow one nudge, calls=%d", got)
|
|
||||||
}
|
|
||||||
now = now.Add(2 * nudgeDebounce)
|
|
||||||
c.RecordWindow(obs("code", "d.go")) // debounce elapsed -> nudge #2
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) < 2 {
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if got := atomic.LoadInt32(&fn.calls); got != 2 {
|
|
||||||
t.Fatalf("expected second nudge after debounce, calls=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNudgeErrorDoesNotFabricate(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
fn := &fakeNudger{err: errors.New("backend down")}
|
|
||||||
c.SetNudge(fn)
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
c.RecordWindow(obs("code", "a.go"))
|
|
||||||
c.RecordWindow(obs("code", "b.go"))
|
|
||||||
deadline := time.Now().Add(1 * time.Second)
|
|
||||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
|
|
||||||
t.Fatalf("error must not set a nudge message: %+v", st.Drift)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNudgeClearsWhenBackOnTrack(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
now := time.Now()
|
|
||||||
c.SetClock(func() time.Time { return now })
|
|
||||||
msgs := make(chan string, 2)
|
|
||||||
msgs <- "drifted"
|
|
||||||
msgs <- "" // back on track
|
|
||||||
c.SetNudge(nudgerFunc(func(context.Context, string, []string) (string, error) {
|
|
||||||
return <-msgs, nil
|
|
||||||
}))
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
c.RecordWindow(obs("code", "a.go"))
|
|
||||||
c.RecordWindow(obs("code", "b.go"))
|
|
||||||
waitNudge(t, c, "drifted")
|
|
||||||
now = now.Add(2 * nudgeDebounce)
|
|
||||||
c.RecordWindow(obs("code", "c.go"))
|
|
||||||
waitNudge(t, c, "") // an on-track result clears the prior advisory
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilNudgerStaysSilentOnTask(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
c.RecordWindow(obs("code", "a.go"))
|
|
||||||
c.RecordWindow(obs("code", "b.go"))
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
st := c.State()
|
|
||||||
if st.Drift == nil || st.Drift.Status != "ontask" {
|
|
||||||
t.Fatalf("expected ontask, got %+v", st.Drift)
|
|
||||||
}
|
|
||||||
if st.Drift.Nudge != "" {
|
|
||||||
t.Fatalf("nil nudger must stay silent, got %q", st.Drift.Nudge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
fn := &fakeNudger{msg: "late", gate: make(chan struct{})}
|
|
||||||
c.SetNudge(fn)
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
c.RecordWindow(obs("code", "a.go"))
|
|
||||||
c.RecordWindow(obs("code", "b.go")) // launches nudge, blocks on gate
|
|
||||||
deadline := time.Now().Add(1 * time.Second)
|
|
||||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
_ = c.Complete()
|
|
||||||
_ = c.End()
|
|
||||||
close(fn.gate) // release stale goroutine; result must be discarded
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if c.State().RuntimeState != domain.RuntimeLocked {
|
|
||||||
t.Fatalf("should be Locked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecentTitlesRingCapsAtTen(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
startActive(t, c, []string{"code"})
|
|
||||||
for i := 0; i < 15; i++ {
|
|
||||||
c.RecordWindow(obs("code", fmt.Sprintf("file%d.go", i)))
|
|
||||||
}
|
|
||||||
titles := c.recentTitlesForTest()
|
|
||||||
if len(titles) != 10 {
|
|
||||||
t.Fatalf("ring should cap at 10, got %d", len(titles))
|
|
||||||
}
|
|
||||||
if titles[0] != "file5.go" {
|
|
||||||
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles'`
|
|
||||||
Expected: build failure — `c.SetNudge`, `c.recentTitlesForTest`, and `nudgeDebounce` are undefined.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add constants and struct fields**
|
|
||||||
|
|
||||||
In `internal/session/session.go`, extend the drift constants block (currently `internal/session/session.go:40-43`) and add a ring-size constant:
|
|
||||||
|
|
||||||
```go
|
|
||||||
const (
|
|
||||||
driftDebounce = 10 * time.Second
|
|
||||||
driftTimeout = 30 * time.Second
|
|
||||||
nudgeDebounce = 5 * time.Minute
|
|
||||||
nudgeTimeout = 30 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
const recentTitlesMax = 10
|
|
||||||
```
|
|
||||||
|
|
||||||
Add nudge fields to the `Controller` struct, immediately after the `judgedClasses` field (`internal/session/session.go:97`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
judgedClasses 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
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add the `Nudge` field to `DriftView`**
|
|
||||||
|
|
||||||
Replace `DriftView` (`internal/session/session.go:117-121`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Wire the nudge message into `stateLocked`**
|
|
||||||
|
|
||||||
Replace the drift block in `stateLocked` (`internal/session/session.go:270-276`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
if c.runtimeState == domain.RuntimeActive {
|
|
||||||
status := c.driftStatus
|
|
||||||
if status == "" {
|
|
||||||
status = driftIdle
|
|
||||||
}
|
|
||||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add `SetNudge`, `commitmentLineLocked`, and clear nudge state in `resetDriftLocked`**
|
|
||||||
|
|
||||||
Add `SetNudge` next to `SetDriftJudge` (after `internal/session/session.go:348`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
|
||||||
// local matching and the drift judge are unaffected.
|
|
||||||
func (c *Controller) SetNudge(n ai.Nudger) {
|
|
||||||
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 *Controller) 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 *Controller) recentTitlesForTest() []string {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return append([]string(nil), c.recentTitles...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Extend `resetDriftLocked` (`internal/session/session.go:350-357`) to clear nudge state too:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
|
||||||
func (c *Controller) resetDriftLocked() {
|
|
||||||
c.driftStatus = driftIdle
|
|
||||||
c.driftReason = ""
|
|
||||||
c.driftGen++
|
|
||||||
c.lastJudgedAt = time.Time{}
|
|
||||||
c.judgedClasses = map[string]ai.Verdict{}
|
|
||||||
c.recentTitles = nil
|
|
||||||
c.nudgeMessage = ""
|
|
||||||
c.lastNudgedAt = time.Time{}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Record the recent-titles ring and DRY the commitment line**
|
|
||||||
|
|
||||||
Add the ring helper near `applyEvent` in `internal/session/session.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
|
||||||
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
|
||||||
func (c *Controller) recordTitleLocked(title string) {
|
|
||||||
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:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In `RecordWindow`, record the title right after `c.applyEvent(now, snap)` (`internal/session/session.go:596`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
c.applyEvent(now, snap)
|
|
||||||
c.recordTitleLocked(snap.Title)
|
|
||||||
launch := c.evaluateDriftLocked(now, snap)
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply the DRY refactor in `evaluateDriftLocked`: replace the inline commitment build (`internal/session/session.go:646-649`) with the helper:
|
|
||||||
|
|
||||||
```go
|
|
||||||
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()
|
|
||||||
```
|
|
||||||
|
|
||||||
(The closure below still references `commitment`, `class`, `title`, `gen`, `prevStatus`, `prevReason` unchanged.)
|
|
||||||
|
|
||||||
- [ ] **Step 8: Launch the nudge from the on-task local-match branch**
|
|
||||||
|
|
||||||
In `evaluateDriftLocked`, replace step 1 (`internal/session/session.go:613-619`) so the on-task branch returns the nudge closure when eligible:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `maybeNudgeLocked` after `evaluateDriftLocked` (before `applyVerdictLocked`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 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 driftGen, never fabricates
|
|
||||||
// a concern on error, and notifies only after unlocking. Caller holds mu and has
|
|
||||||
// already verified the runtime is Active.
|
|
||||||
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
|
||||||
if c.nudge == nil || len(c.recentTitles) < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
c.lastNudgedAt = now
|
|
||||||
gen := c.driftGen
|
|
||||||
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 gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return // stale: a new session started or the session ended
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
c.mu.Unlock()
|
|
||||||
log.Printf("session: 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 9: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles' -v`
|
|
||||||
Expected: PASS for all eight new tests.
|
|
||||||
|
|
||||||
- [ ] **Step 10: Run the full session suite under the race detector**
|
|
||||||
|
|
||||||
Run: `go test -race ./internal/session/`
|
|
||||||
Expected: PASS (all existing drift tests still green; no data races).
|
|
||||||
|
|
||||||
- [ ] **Step 11: Vet**
|
|
||||||
|
|
||||||
Run: `go vet ./internal/session/`
|
|
||||||
Expected: no output.
|
|
||||||
|
|
||||||
- [ ] **Step 12: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/session/session.go internal/session/session_test.go
|
|
||||||
git commit -m "Orchestrate semantic nudge on the on-task path
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Assert the nudge surfaces in the SSE state payload
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: `internal/web/web_test.go`
|
|
||||||
|
|
||||||
`internal/web/web.go` needs no change — `DriftView.Nudge` serializes automatically. This task adds a web-package test proving the field reaches the wire. Reuse `newTestServer`/`post` (`internal/web/web_test.go:20-38`) and the `stubCoach` pattern (`:105-112`). The `/commitment` route already accepts `allowed_window_classes` (added in M3). `context` is already imported in this test file.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Append to `internal/web/web_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type stubNudger struct{ msg string }
|
|
||||||
|
|
||||||
func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
|
||||||
return s.msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
|
||||||
r := s.Router()
|
|
||||||
_ = post(t, r, "/planning", "")
|
|
||||||
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
|
||||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
// Two distinct on-task titles trip the nudge (history >= 2, first call has no
|
|
||||||
// debounce to wait on).
|
|
||||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
|
|
||||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
|
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
js := s.stateJSON()
|
|
||||||
if !strings.Contains(js, `"nudge":"drifted to unrelated work"`) {
|
|
||||||
t.Fatalf("payload missing nudge: %s", js)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails first, then passes**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ -run TestActiveStatePayloadCarriesNudge -v`
|
|
||||||
Expected: PASS (the implementation already exists from Task 2; this guards the serialization). If it fails to compile because `stubNudger` is unused elsewhere, that is fine — it is used by this test.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the full web suite and vet**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ && go vet ./internal/web/`
|
|
||||||
Expected: PASS; no vet output.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/web/web_test.go
|
|
||||||
git commit -m "Assert semantic nudge reaches the state payload
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Active-view "Heads up" soft tier
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/web/static/index.html`
|
|
||||||
|
|
||||||
The active view already renders drift from `state.drift` via `updateActiveDrift(drift)` (`internal/web/static/index.html:80-101`), called both on the active fast-path (`:135-137`) and full render (`:188`). The `#drift` div is the first child of the active view (`:177`). You will add a soft, dismiss-only "Heads up" tier that shows when `drift.nudge` is non-empty and the user is not drifting. Match the existing un-escaped interpolation style used for `drift.reason` (these are short, locally generated strings).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the nudge CSS**
|
|
||||||
|
|
||||||
After the existing `.drift-hint` rule (`internal/web/static/index.html:36`), add:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.nudge { background: #1d2630; border: 1px solid #2f4255; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px; }
|
|
||||||
.nudge-title { font-weight: 600; color: #8fb6d9; }
|
|
||||||
.nudge-msg { color: #c2d2e0; margin: 4px 0 8px; }
|
|
||||||
.nudge-actions button { padding: 6px 12px; background: #2d3242; color: #cdd2e0; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the dismiss state and the nudge tier**
|
|
||||||
|
|
||||||
Add a module-level variable next to `let renderedState = null;` (`internal/web/static/index.html:47`):
|
|
||||||
|
|
||||||
```js
|
|
||||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `updateActiveDrift` (`internal/web/static/index.html:80-101`) with:
|
|
||||||
|
|
||||||
```js
|
|
||||||
function updateActiveDrift(drift) {
|
|
||||||
const el = document.getElementById('drift');
|
|
||||||
if (!el) return;
|
|
||||||
const status = drift && drift.status;
|
|
||||||
if (status === 'drifting') {
|
|
||||||
el.innerHTML = `<div class="drift">
|
|
||||||
<div class="drift-title">⚠ Possible drift</div>
|
|
||||||
<div class="drift-reason">${drift.reason || ''}</div>
|
|
||||||
<div class="drift-actions">
|
|
||||||
<button id="refocus" type="button">Back to task</button>
|
|
||||||
<button id="ontask" type="button" class="secondary">This is on task</button>
|
|
||||||
<button id="enddrift" type="button" class="secondary">End session</button>
|
|
||||||
</div></div>`;
|
|
||||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
|
||||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
|
||||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nudge = (drift && drift.nudge) || '';
|
|
||||||
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
|
|
||||||
if (nudge && nudge !== dismissedNudge) {
|
|
||||||
el.innerHTML = `<div class="nudge">
|
|
||||||
<div class="nudge-title">Heads up</div>
|
|
||||||
<div class="nudge-msg">${nudge}</div>
|
|
||||||
<div class="nudge-actions">
|
|
||||||
<button id="dismissnudge" type="button">Dismiss</button>
|
|
||||||
</div></div>`;
|
|
||||||
document.getElementById('dismissnudge').onclick = () => {
|
|
||||||
dismissedNudge = nudge;
|
|
||||||
const e = document.getElementById('drift');
|
|
||||||
if (e) e.innerHTML = '';
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status === 'pending') {
|
|
||||||
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = '';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the build still serves the page**
|
|
||||||
|
|
||||||
Run: `go build ./... && go vet ./...`
|
|
||||||
Expected: no output (the HTML is embedded; this confirms nothing else broke).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual smoke check of the markup (optional but recommended)**
|
|
||||||
|
|
||||||
Run: `grep -n "Heads up" internal/web/static/index.html`
|
|
||||||
Expected: one match inside `updateActiveDrift`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/web/static/index.html
|
|
||||||
git commit -m "Add dismiss-only Heads up tier for semantic nudges
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Wire the nudge into the daemon
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `cmd/antidriftd/main.go`
|
|
||||||
|
|
||||||
The single `ai.Service` already satisfies `Coach` and `DriftJudge` and is wired at `cmd/antidriftd/main.go:41-44`. Add the third role.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `SetNudge` and update the log line**
|
|
||||||
|
|
||||||
Replace the AI-wiring body (`cmd/antidriftd/main.go:41-44`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
svc := ai.NewService(backend)
|
|
||||||
ctrl.SetCoach(svc)
|
|
||||||
ctrl.SetDriftJudge(svc)
|
|
||||||
ctrl.SetNudge(svc)
|
|
||||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Build and vet**
|
|
||||||
|
|
||||||
Run: `go build ./cmd/antidriftd && go vet ./cmd/antidriftd`
|
|
||||||
Expected: no output.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add cmd/antidriftd/main.go
|
|
||||||
git commit -m "Wire the semantic nudge into the daemon
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Document M3.5 in the README
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `README.md`
|
|
||||||
|
|
||||||
The Status section currently leads with the M3 paragraph (`README.md:26-32`). Add an M3.5 paragraph above it so the newest milestone is first.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Insert the M3.5 paragraph**
|
|
||||||
|
|
||||||
Immediately after the `## Status` heading (`README.md:24`) and its blank line, insert:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
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.
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify placement**
|
|
||||||
|
|
||||||
Run: `grep -n "M3.5\|M3 (drift\|M2 (AI" README.md`
|
|
||||||
Expected: `M3.5` line appears before the `M3 (drift` line, which appears before `M2 (AI`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add README.md
|
|
||||||
git commit -m "Document M3.5 semantic nudge in README
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final Review
|
|
||||||
|
|
||||||
After all tasks, dispatch a final whole-diff code review and run the complete suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build ./... && go vet ./... && go test -race ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: clean build, no vet output, all tests pass under the race detector.
|
|
||||||
|
|
||||||
Confirm end-to-end: with a real AI backend, an Active session whose allowed list includes the focused app, sitting in that app while the window titles wander off-commitment, should — after ~5 min and ≥2 distinct titles — surface a soft "Heads up" line that the user can dismiss, with no hard interrupt and no overlap with the drift banner. Without a backend (`SetNudge(nil)` path), nothing changes.
|
|
||||||
```
|
|
||||||
@@ -1,625 +0,0 @@
|
|||||||
# M4 — "Look good" 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:** Restyle the AntiDrift web UI into a cockpit-style, state-aware stacked HUD, split CSS/JS out of the inline HTML, and polish the review screen — with zero behavior changes.
|
|
||||||
|
|
||||||
**Architecture:** The UI is a single embedded static bundle served by `internal/web`. Today everything lives inline in `static/index.html`. We split it into `index.html` + `app.css` + `app.js`, add Go routes to serve the two new assets from the existing `embed.FS`, then rework the client markup into reusable "band" rows whose accent color is driven by a `data-state` attribute on `<main>`. All SSE wiring, element ids, and POST endpoints are preserved, so the existing Go endpoint tests keep passing.
|
|
||||||
|
|
||||||
**Tech Stack:** Go 1.26, Gin, `embed.FS`, server-sent events; plain HTML/CSS/vanilla JS (no framework, no build step). Tests: stdlib `testing` + `net/http/httptest`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reference: shared CSS class vocabulary
|
|
||||||
|
|
||||||
These class/attribute names are used by BOTH `app.css` and `app.js`. Keep them identical across tasks:
|
|
||||||
|
|
||||||
- `<main id="app" data-state="...">` — `data-state` ∈ `locked | planning | active | nudge | drift | review`. Drives `--accent`.
|
|
||||||
- `.band` — a horizontal HUD row (top divider + padding). `.band:first-child` has no top border.
|
|
||||||
- `.statusband` — the top band; shows the state pill + status meta, takes a thicker accent top border.
|
|
||||||
- `.pill` — uppercase state label, colored by `--accent`.
|
|
||||||
- `.status-meta` — dim text beside the pill (e.g. "on task · 7 switches").
|
|
||||||
- `.timer` — large tabular-nums countdown.
|
|
||||||
- `.action` — the next-action line. `.meta` — dim secondary text (e.g. "done when: …").
|
|
||||||
- `.evidence` (band) with children `.now`, `.buckets` (`<ul>`/`<li>`), `.switches`; health spans `.health-ok` / `.health-bad`.
|
|
||||||
- `.summary` (review band) with `.summary-row` children.
|
|
||||||
- Buttons: `.btn`, `.btn-primary`, `.btn-ghost`.
|
|
||||||
- The `data-state` value is derived by `stateKey(state)` (defined in Task 2), which accounts for the client-side `dismissedNudge`.
|
|
||||||
|
|
||||||
Preserved element ids (do not rename): `#app`, `#intent`, `#sharpen`, `#coachStatus`, `#na`, `#sc`, `#mins`, `#apps`, `#start`, `#statusband`, `#t`, `#done`, `#end`, `#plan`, `#refocus`, `#ontask`, `#enddrift`, `#dismissnudge`.
|
|
||||||
|
|
||||||
Preserved endpoints: `GET /events`; `POST /planning /coach /commitment /complete /end /refocus /ontask`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Split assets out of the inline HTML and serve them
|
|
||||||
|
|
||||||
Mechanical, behavior-preserving: move the inline `<style>`/`<script>` into `app.css`/`app.js`, link them, and add Go routes so they are served from the embedded FS. No visual or logic change yet.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/web/static/app.css`
|
|
||||||
- Create: `internal/web/static/app.js`
|
|
||||||
- Modify: `internal/web/static/index.html` (replace whole file)
|
|
||||||
- Modify: `internal/web/web.go:39-56` (add two asset routes)
|
|
||||||
- Test: `internal/web/web_test.go` (add one test func)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test for asset serving**
|
|
||||||
|
|
||||||
Add to `internal/web/web_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestServesStaticAssets(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
r := s.Router()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
path string
|
|
||||||
ctSubstr string
|
|
||||||
}{
|
|
||||||
{"/app.css", "css"},
|
|
||||||
{"/app.js", "javascript"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("GET %s code = %d, want 200", tc.path, w.Code)
|
|
||||||
}
|
|
||||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, tc.ctSubstr) {
|
|
||||||
t.Errorf("GET %s Content-Type = %q, want substring %q", tc.path, ct, tc.ctSubstr)
|
|
||||||
}
|
|
||||||
if w.Body.Len() == 0 {
|
|
||||||
t.Errorf("GET %s body is empty", tc.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ -run TestServesStaticAssets -v`
|
|
||||||
Expected: FAIL — routes return 404 (and the embedded files don't exist yet).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create `app.css` by moving the inline styles verbatim**
|
|
||||||
|
|
||||||
Cut the entire current contents of the `<style>…</style>` block in `internal/web/static/index.html` (everything between the tags, currently lines 8–41) and paste it unchanged into the new file `internal/web/static/app.css`. Do not edit the rules in this task — this is a pure move. (They are replaced wholesale in Task 2.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create `app.js` by moving the inline script verbatim**
|
|
||||||
|
|
||||||
Cut the entire current contents of the `<script>…</script>` block in `internal/web/static/index.html` (everything between the tags, currently lines 49–227) and paste it unchanged into the new file `internal/web/static/app.js`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Replace `index.html` with the markup shell**
|
|
||||||
|
|
||||||
Replace the whole file `internal/web/static/index.html` with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>AntiDrift</title>
|
|
||||||
<link rel="stylesheet" href="/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main id="app">
|
|
||||||
<h1>AntiDrift</h1>
|
|
||||||
<div class="card" id="view">connecting…</div>
|
|
||||||
</main>
|
|
||||||
<script src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: the script must reference `view` and (from Task 2 on) `app`; both ids exist here. The script tag is at the end of `<body>`, so the DOM is parsed before it runs (matches the current inline-at-end-of-body behavior).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add asset routes in `web.go`**
|
|
||||||
|
|
||||||
In `internal/web/web.go`, the `Router()` method currently has (around lines 43–46):
|
|
||||||
|
|
||||||
```go
|
|
||||||
sub, _ := fs.Sub(staticFS, "static")
|
|
||||||
r.GET("/", func(c *gin.Context) {
|
|
||||||
c.FileFromFS("/", http.FS(sub))
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the two asset routes immediately after the `r.GET("/", …)` block:
|
|
||||||
|
|
||||||
```go
|
|
||||||
r.GET("/app.css", func(c *gin.Context) {
|
|
||||||
c.FileFromFS("/app.css", http.FS(sub))
|
|
||||||
})
|
|
||||||
r.GET("/app.js", func(c *gin.Context) {
|
|
||||||
c.FileFromFS("/app.js", http.FS(sub))
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run the asset test and the full web suite**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ -v`
|
|
||||||
Expected: PASS — `TestServesStaticAssets` passes and all pre-existing web tests still pass (they assert on endpoints/JSON, not markup).
|
|
||||||
|
|
||||||
- [ ] **Step 8: Verify vet and the whole module**
|
|
||||||
|
|
||||||
Run: `go vet ./... && go test -race ./...`
|
|
||||||
Expected: clean, all green.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/web/static/index.html internal/web/static/app.css internal/web/static/app.js internal/web/web.go internal/web/web_test.go
|
|
||||||
git commit -m "M4: split web UI assets into app.css and app.js
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Cockpit visual system + locked/planning/active/drift/nudge
|
|
||||||
|
|
||||||
Replace `app.css` with the new design system and rewrite `app.js`'s rendering into stacked bands with a `data-state`-driven accent. Review stays a simple functional band here; Task 3 turns it into the recap. No Go changes — the existing endpoint tests are the regression guard.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/web/static/app.css` (replace whole file)
|
|
||||||
- Modify: `internal/web/static/app.js` (replace whole file)
|
|
||||||
- Test: existing `internal/web/web_test.go` (regression only — no new test)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace `app.css` with the cockpit design system**
|
|
||||||
|
|
||||||
Replace the whole file `internal/web/static/app.css` with:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
--bg: #0d0f14;
|
|
||||||
--panel: #161922;
|
|
||||||
--line: #232733;
|
|
||||||
--ink: #e6e8ee;
|
|
||||||
--ink-dim: #8b91a6;
|
|
||||||
--ok: #34d399;
|
|
||||||
--warn: #f0b429;
|
|
||||||
--danger: #f06070;
|
|
||||||
--accent: #6b7280; /* default / locked */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The single state-driven knob: only --accent changes per state. */
|
|
||||||
[data-state="planning"] { --accent: #4c6ef5; }
|
|
||||||
[data-state="active"] { --accent: #34d399; }
|
|
||||||
[data-state="nudge"] { --accent: #f0b429; }
|
|
||||||
[data-state="drift"] { --accent: #f06070; }
|
|
||||||
[data-state="review"] { --accent: #a78bfa; }
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font: 15px/1.5 system-ui, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
main { max-width: 560px; margin: 7vh auto; padding: 0 20px; }
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 12px; letter-spacing: .28em; text-transform: uppercase;
|
|
||||||
color: var(--ink-dim); margin: 0 0 14px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The HUD card: a stack of bands. */
|
|
||||||
.card {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.band { border-top: 1px solid var(--line); padding: 16px 20px; }
|
|
||||||
.band:first-child { border-top: 0; }
|
|
||||||
|
|
||||||
.statusband {
|
|
||||||
display: flex; align-items: baseline; gap: 12px;
|
|
||||||
border-top: 3px solid var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, var(--panel));
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
font-size: 11px; letter-spacing: .18em; text-transform: uppercase;
|
|
||||||
font-weight: 700; color: var(--accent);
|
|
||||||
}
|
|
||||||
.status-meta { font-size: 13px; color: var(--ink-dim); }
|
|
||||||
|
|
||||||
.timer {
|
|
||||||
font-size: 52px; font-weight: 700; line-height: 1;
|
|
||||||
font-variant-numeric: tabular-nums; color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action { font-size: 19px; font-weight: 600; }
|
|
||||||
.meta { color: var(--ink-dim); margin: 4px 0 0; }
|
|
||||||
|
|
||||||
label { display: block; font-size: 12px; color: var(--ink-dim); margin: 14px 0 5px; }
|
|
||||||
label:first-child { margin-top: 0; }
|
|
||||||
input {
|
|
||||||
width: 100%; padding: 10px 12px;
|
|
||||||
background: var(--bg); border: 1px solid var(--line);
|
|
||||||
border-radius: 8px; color: var(--ink); font: inherit;
|
|
||||||
}
|
|
||||||
input:focus { outline: 0; border-color: var(--accent); }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
|
|
||||||
font: inherit; font-weight: 600; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-primary { background: var(--accent); color: #0d0f14; }
|
|
||||||
.btn-ghost { background: var(--line); color: var(--ink); }
|
|
||||||
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* Drift/nudge actions live in the status band; lay them out below the text. */
|
|
||||||
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
|
|
||||||
|
|
||||||
.drift-reason, .nudge-msg { color: var(--ink); margin: 4px 0 0; font-weight: 400; }
|
|
||||||
.statusband.col { flex-direction: column; align-items: stretch; gap: 6px; }
|
|
||||||
|
|
||||||
/* Evidence band */
|
|
||||||
.evidence .now { font-size: 13px; color: var(--ink); }
|
|
||||||
.health-ok { color: var(--ok); }
|
|
||||||
.health-bad { color: var(--warn); }
|
|
||||||
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: var(--ink-dim); }
|
|
||||||
.buckets li {
|
|
||||||
display: flex; justify-content: space-between; padding: 2px 0;
|
|
||||||
font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.switches { font-size: 12px; color: var(--ink-dim); margin-top: 8px; }
|
|
||||||
|
|
||||||
/* Review summary band (built out in Task 3) */
|
|
||||||
.summary { font-size: 14px; }
|
|
||||||
.summary-row {
|
|
||||||
display: flex; justify-content: space-between; padding: 3px 0;
|
|
||||||
font-variant-numeric: tabular-nums; color: var(--ink-dim);
|
|
||||||
}
|
|
||||||
.summary-row span:last-child { color: var(--ink); font-family: ui-monospace, monospace; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Replace `app.js` with the band-based renderer**
|
|
||||||
|
|
||||||
Replace the whole file `internal/web/static/app.js` with:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const app = document.getElementById('app');
|
|
||||||
const view = document.getElementById('view');
|
|
||||||
let countdownTimer = null;
|
|
||||||
let renderedState = null; // which runtime_state the DOM currently shows
|
|
||||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
|
||||||
|
|
||||||
function post(path, body) {
|
|
||||||
return fetch(path, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(secs) {
|
|
||||||
secs = Math.max(0, Math.floor(secs));
|
|
||||||
const m = String(Math.floor(secs / 60)).padStart(2, '0');
|
|
||||||
const s = String(secs % 60).padStart(2, '0');
|
|
||||||
return `${m}:${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateKey maps server state to the data-state accent bucket. Drift outranks
|
|
||||||
// a nudge, and a nudge the user already dismissed reverts to plain active.
|
|
||||||
function stateKey(state) {
|
|
||||||
const rs = state.runtime_state;
|
|
||||||
if (rs !== 'active') return rs || 'locked';
|
|
||||||
const d = state.drift || {};
|
|
||||||
if (d.status === 'drifting') return 'drift';
|
|
||||||
if (d.nudge && d.nudge !== dismissedNudge) return 'nudge';
|
|
||||||
return 'active';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStateAttr(state) {
|
|
||||||
app.dataset.state = stateKey(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
function evidenceBlock(ev) {
|
|
||||||
if (!ev) return '';
|
|
||||||
const health = ev.available
|
|
||||||
? `<span class="health-ok">tracking</span>`
|
|
||||||
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
|
||||||
const now = ev.current && (ev.current.class || ev.current.title)
|
|
||||||
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
|
|
||||||
const rows = (ev.buckets || []).map(b =>
|
|
||||||
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
|
|
||||||
return `<div class="band evidence">
|
|
||||||
<div class="now">now ${now} ${health}</div>
|
|
||||||
<ul class="buckets">${rows}</ul>
|
|
||||||
<div class="switches">context switches: ${ev.switch_count || 0}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateActiveDrift owns the active status band: it renders on-task, nudge,
|
|
||||||
// drift, or pending content into #statusband and rebinds the buttons.
|
|
||||||
function updateActiveDrift(state) {
|
|
||||||
const el = document.getElementById('statusband');
|
|
||||||
if (!el) return;
|
|
||||||
setStateAttr(state);
|
|
||||||
const drift = state.drift || {};
|
|
||||||
const switches = (state.evidence && state.evidence.switch_count) || 0;
|
|
||||||
|
|
||||||
if (drift.status === 'drifting') {
|
|
||||||
el.className = 'band statusband col';
|
|
||||||
el.innerHTML = `<div><span class="pill">Drift</span></div>
|
|
||||||
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
|
|
||||||
<div class="band-actions">
|
|
||||||
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
|
|
||||||
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
|
||||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
|
||||||
</div>`;
|
|
||||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
|
||||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
|
||||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nudge = drift.nudge || '';
|
|
||||||
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
|
|
||||||
if (nudge && nudge !== dismissedNudge) {
|
|
||||||
el.className = 'band statusband col';
|
|
||||||
el.innerHTML = `<div><span class="pill">Heads up</span></div>
|
|
||||||
<div class="nudge-msg">${nudge}</div>
|
|
||||||
<div class="band-actions">
|
|
||||||
<button id="dismissnudge" type="button" class="btn btn-ghost">Dismiss</button>
|
|
||||||
</div>`;
|
|
||||||
document.getElementById('dismissnudge').onclick = () => {
|
|
||||||
dismissedNudge = nudge;
|
|
||||||
setStateAttr(state);
|
|
||||||
updateActiveDrift(state);
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drift.status === 'pending') {
|
|
||||||
el.className = 'band statusband';
|
|
||||||
el.innerHTML = `<span class="pill">Active</span><span class="status-meta">checking focus…</span>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.className = 'band statusband';
|
|
||||||
el.innerHTML = `<span class="pill">Active</span>
|
|
||||||
<span class="status-meta">on task · ${switches} switches</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePlanningCoach(coach) {
|
|
||||||
const statusEl = document.getElementById('coachStatus');
|
|
||||||
if (!statusEl) return;
|
|
||||||
if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; }
|
|
||||||
if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; }
|
|
||||||
if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; }
|
|
||||||
if (coach.status === 'ready' && coach.proposal) {
|
|
||||||
statusEl.textContent = 'AI suggestion applied — edit freely.';
|
|
||||||
const stamp = JSON.stringify(coach.proposal);
|
|
||||||
if (statusEl.dataset.applied === stamp) return;
|
|
||||||
statusEl.dataset.applied = stamp;
|
|
||||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
|
||||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
|
||||||
if (na && !na.value.trim()) na.value = coach.proposal.next_action || '';
|
|
||||||
if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
|
|
||||||
if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
|
|
||||||
if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
|
|
||||||
const apps = document.getElementById('apps');
|
|
||||||
if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
|
|
||||||
apps.value = coach.proposal.allowed_window_classes.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(state) {
|
|
||||||
setStateAttr(state);
|
|
||||||
const rs = state.runtime_state;
|
|
||||||
if (rs === 'planning' && renderedState === 'planning') {
|
|
||||||
updatePlanningCoach(state.coach);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (rs === 'active' && renderedState === 'active') {
|
|
||||||
updateActiveDrift(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
|
||||||
renderedState = rs;
|
|
||||||
|
|
||||||
if (rs === 'locked') {
|
|
||||||
view.innerHTML = `<div class="band statusband"><span class="pill">Locked</span></div>
|
|
||||||
<div class="band">
|
|
||||||
<p class="meta">No active commitment.</p>
|
|
||||||
<button id="plan" class="btn btn-primary">Start planning</button>
|
|
||||||
</div>`;
|
|
||||||
document.getElementById('plan').onclick = () => post('/planning');
|
|
||||||
|
|
||||||
} else if (rs === 'planning') {
|
|
||||||
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
|
||||||
<div class="band">
|
|
||||||
<label>Rough intent</label>
|
|
||||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
|
||||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
|
||||||
<div id="coachStatus" class="meta"></div>
|
|
||||||
</div>
|
|
||||||
<div class="band">
|
|
||||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
|
||||||
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
|
||||||
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
|
||||||
<label>Allowed apps (comma-separated window classes)</label>
|
|
||||||
<input id="apps" placeholder="e.g. code, firefox">
|
|
||||||
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
|
||||||
</div>`;
|
|
||||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
|
||||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
|
||||||
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
|
||||||
[na, sc, mins].forEach(el => el.oninput = check);
|
|
||||||
start.onclick = () => post('/commitment', {
|
|
||||||
next_action: na.value.trim(),
|
|
||||||
success_condition: sc.value.trim(),
|
|
||||||
timebox_secs: Math.round(+mins.value * 60),
|
|
||||||
allowed_window_classes: (document.getElementById('apps').value || '')
|
|
||||||
.split(',').map(s => s.trim()).filter(Boolean),
|
|
||||||
});
|
|
||||||
document.getElementById('sharpen').onclick = () => {
|
|
||||||
const intent = document.getElementById('intent').value.trim();
|
|
||||||
if (intent) post('/coach', { intent });
|
|
||||||
};
|
|
||||||
updatePlanningCoach(state.coach);
|
|
||||||
|
|
||||||
} else if (rs === 'active') {
|
|
||||||
const c = state.commitment || {};
|
|
||||||
view.innerHTML = `<div class="band statusband" id="statusband"></div>
|
|
||||||
<div class="band"><div class="timer" id="t">--:--</div></div>
|
|
||||||
<div class="band">
|
|
||||||
<div class="action">${c.next_action || ''}</div>
|
|
||||||
<p class="meta">done when: ${c.success_condition || ''}</p>
|
|
||||||
</div>
|
|
||||||
${evidenceBlock(state.evidence)}
|
|
||||||
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
|
|
||||||
document.getElementById('done').onclick = () => post('/complete');
|
|
||||||
const t = document.getElementById('t');
|
|
||||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
|
||||||
tick();
|
|
||||||
countdownTimer = setInterval(tick, 250);
|
|
||||||
updateActiveDrift(state);
|
|
||||||
|
|
||||||
} else if (rs === 'review') {
|
|
||||||
const c = state.commitment || {};
|
|
||||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
|
||||||
<div class="band">
|
|
||||||
<div class="action">Session ended</div>
|
|
||||||
<p class="meta">${c.next_action || ''}</p>
|
|
||||||
</div>
|
|
||||||
${evidenceBlock(state.evidence)}
|
|
||||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
|
||||||
document.getElementById('end').onclick = () => post('/end');
|
|
||||||
|
|
||||||
} else {
|
|
||||||
view.textContent = rs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = new EventSource('/events');
|
|
||||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
|
||||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the web suite (regression guard)**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ -v`
|
|
||||||
Expected: PASS — all endpoint/asset tests still green (markup changed, contracts did not).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual visual check**
|
|
||||||
|
|
||||||
Run the daemon and click through states:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/antidriftd
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify in the browser (http://localhost:7777):
|
|
||||||
- Locked: gray accent, "Start planning".
|
|
||||||
- Planning: blue accent, all fields, Sharpen + Start.
|
|
||||||
- Active on-task: green accent, "Active · on task · N switches", big timer, evidence band.
|
|
||||||
- Drift (force by setting allowed apps then switching to an off-list window): red accent, reason + three buttons.
|
|
||||||
- Nudge (semantic nudge within an allowed app): amber accent, message + Dismiss; after Dismiss the accent returns to green.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/web/static/app.css internal/web/static/app.js
|
|
||||||
git commit -m "M4: cockpit HUD with state-driven accent
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Polish the review screen into a session recap
|
|
||||||
|
|
||||||
Turn the bare review state into a presentational summary built from data the state already carries (`commitment` + `evidence`). No new backend data.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/web/static/app.js` (add `reviewSummary` helper; swap the review branch's markup)
|
|
||||||
- Modify: `internal/web/static/app.css` (`.summary` styles already added in Task 2 — verify, no change expected)
|
|
||||||
- Test: existing `internal/web/web_test.go` (regression only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the `reviewSummary` helper to `app.js`**
|
|
||||||
|
|
||||||
Insert this function immediately after `evidenceBlock` in `internal/web/static/app.js`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// reviewSummary renders a presentational recap from already-available state:
|
|
||||||
// the commitment plus the per-window evidence buckets. No new backend data.
|
|
||||||
function reviewSummary(ev) {
|
|
||||||
if (!ev) return '';
|
|
||||||
const rows = (ev.buckets || []).map(b =>
|
|
||||||
`<div class="summary-row"><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></div>`).join('');
|
|
||||||
const switches = `<div class="summary-row"><span>context switches</span><span>${ev.switch_count || 0}</span></div>`;
|
|
||||||
return `<div class="band summary">${switches}${rows}</div>`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Swap the review branch markup to use the recap**
|
|
||||||
|
|
||||||
In `internal/web/static/app.js`, find the review branch (the `else if (rs === 'review')` block) and replace its `view.innerHTML = …` assignment. Change FROM:
|
|
||||||
|
|
||||||
```js
|
|
||||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
|
||||||
<div class="band">
|
|
||||||
<div class="action">Session ended</div>
|
|
||||||
<p class="meta">${c.next_action || ''}</p>
|
|
||||||
</div>
|
|
||||||
${evidenceBlock(state.evidence)}
|
|
||||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
|
||||||
```
|
|
||||||
|
|
||||||
TO:
|
|
||||||
|
|
||||||
```js
|
|
||||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
|
||||||
<div class="band">
|
|
||||||
<div class="action">Session ended</div>
|
|
||||||
<p class="meta">${c.next_action || ''}</p>
|
|
||||||
<p class="meta">done when: ${c.success_condition || ''}</p>
|
|
||||||
</div>
|
|
||||||
${reviewSummary(state.evidence)}
|
|
||||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
|
||||||
```
|
|
||||||
|
|
||||||
(The verbose live `evidenceBlock` is replaced by the calmer `reviewSummary` recap; the "now / tracking" line belongs to the active HUD, not the post-session summary.)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the web suite (regression guard)**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ -v`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual visual check of review**
|
|
||||||
|
|
||||||
Start a short commitment (1 minute) with allowed apps, switch between a couple of windows, let it expire (or click Complete), and confirm the review screen shows: violet accent, "Session ended", the action + success condition, then a summary band listing context switches and the per-window time buckets, then End.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Final module verification**
|
|
||||||
|
|
||||||
Run: `go vet ./... && go test -race ./...`
|
|
||||||
Expected: clean, all green.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/web/static/app.js internal/web/static/app.css
|
|
||||||
git commit -m "M4: presentational review recap
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## After all tasks
|
|
||||||
|
|
||||||
Dispatch a final code reviewer over the three commits, then use **superpowers:finishing-a-development-branch** to close out M4.
|
|
||||||
|
|
||||||
Done when: the UI renders the cockpit HUD across all six `data-state` values, CSS/JS are served as separate embedded assets, the review screen shows a recap, and `go vet ./... && go test -race ./...` is clean.
|
|
||||||
@@ -1,794 +0,0 @@
|
|||||||
# M5 — Tasks Port 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:** Add a `tasks.Provider` port with an Amazing Marvin adapter (`am --json`) that lists today's open tasks; surface them on the planning screen as clickable chips that seed the intent field.
|
|
||||||
|
|
||||||
**Architecture:** A new leaf package `internal/tasks` (interface + primitives only, like `ai`) with a CLI adapter that shells out to ampy's `am --json`. The `session.Controller` fetches the list asynchronously on entering planning — mirroring the existing planning coach (generation counter, status enum, graceful degradation) — and projects it into the broadcast `State`. The browser renders the list during planning; clicking a task fills `#intent`, which flows into the unchanged coach pipeline. Read-only; no writeback; no new endpoints.
|
|
||||||
|
|
||||||
**Tech Stack:** Go 1.26, stdlib `os/exec` + `encoding/json`, Gin + SSE (unchanged), vanilla JS/CSS, stdlib `testing`.
|
|
||||||
|
|
||||||
**Reference:** Design spec at `docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md`. The patterns being mirrored: the `ai` port (`internal/ai/coach.go`, `internal/ai/backend.go`) and the planning coach in `internal/session/session.go` (`RequestCoach`, `resetCoachLocked`, `CoachView`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- **Create `internal/tasks/tasks.go`** — the `Provider` interface and the `Task` value type. Leaf package; imports only `context`.
|
|
||||||
- **Create `internal/tasks/marvin.go`** — the `Marvin` adapter (shells out to `am --json`), an injectable `runner`, and the pure `parse` function.
|
|
||||||
- **Create `internal/tasks/marvin_test.go`** — `parse` table tests, command-splitting test, and adapter tests with a fake runner.
|
|
||||||
- **Modify `internal/session/session.go`** — add tasks fields, `TaskView`/`TasksView`, `State.Tasks`, `SetTasks`, `startTasksFetchLocked`, the planning-only projection, and the `EnterPlanning` hook.
|
|
||||||
- **Modify `internal/session/session_test.go`** — `fakeProvider`, `waitTasksStatus`, and four controller tests.
|
|
||||||
- **Modify `cmd/antidriftd/main.go`** — construct the Marvin adapter and call `ctrl.SetTasks`.
|
|
||||||
- **Modify `internal/web/web_test.go`** — `stubProvider` and a planning-payload-carries-tasks test.
|
|
||||||
- **Modify `internal/web/static/app.js`** — `updatePlanningTasks`, a tasks band in the planning render, and calls from both render paths.
|
|
||||||
- **Modify `internal/web/static/app.css`** — `.tasklist` / `.task-chip` styling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: `tasks` package (port + Marvin adapter)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/tasks/tasks.go`
|
|
||||||
- Create: `internal/tasks/marvin.go`
|
|
||||||
- Test: `internal/tasks/marvin_test.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the port interface and value type**
|
|
||||||
|
|
||||||
Create `internal/tasks/tasks.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Package tasks is the Tasks port: it answers "what should I be doing?" by
|
|
||||||
// listing the open to-do items due today or earlier. It imports nothing from
|
|
||||||
// the rest of the app, so it stays a leaf package.
|
|
||||||
package tasks
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// Task is one to-do item. Primitives only, so tasks stays a leaf package.
|
|
||||||
type Task struct {
|
|
||||||
ID string
|
|
||||||
Title string
|
|
||||||
Day string // "YYYY-MM-DD", or "" if unscheduled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider answers "what should I be doing?" — the open tasks due today or
|
|
||||||
// earlier.
|
|
||||||
type Provider interface {
|
|
||||||
Today(ctx context.Context) ([]Task, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write the failing adapter tests**
|
|
||||||
|
|
||||||
Create `internal/tasks/marvin_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package tasks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParse(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in string
|
|
||||||
want []Task
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid array",
|
|
||||||
in: `[{"id":"a","title":"Write spec","parentId":"p","day":"2026-05-31","done":false}]`,
|
|
||||||
want: []Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}},
|
|
||||||
},
|
|
||||||
{name: "empty array", in: `[]`, want: []Task{}},
|
|
||||||
{name: "json null", in: `null`, want: []Task{}},
|
|
||||||
{
|
|
||||||
name: "drops done and empty-title",
|
|
||||||
in: `[{"id":"a","title":"keep","day":"","done":false},{"id":"b","title":"done one","done":true},{"id":"c","title":" ","done":false}]`,
|
|
||||||
want: []Task{{ID: "a", Title: "keep", Day: ""}},
|
|
||||||
},
|
|
||||||
{name: "malformed", in: `not json`, wantErr: true},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got, err := parse([]byte(tc.in))
|
|
||||||
if tc.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("want error, got nil")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != len(tc.want) {
|
|
||||||
t.Fatalf("len = %d, want %d (%+v)", len(got), len(tc.want), got)
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != tc.want[i] {
|
|
||||||
t.Errorf("[%d] = %+v, want %+v", i, got[i], tc.want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewMarvinCommandSplitting(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
in string
|
|
||||||
wantCmd string
|
|
||||||
wantArgs []string
|
|
||||||
}{
|
|
||||||
{"", "am", []string{"--json"}},
|
|
||||||
{"am", "am", []string{"--json"}},
|
|
||||||
{"uv run am", "uv", []string{"run", "am", "--json"}},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
m := NewMarvin(tc.in)
|
|
||||||
if m.cmd != tc.wantCmd {
|
|
||||||
t.Errorf("NewMarvin(%q).cmd = %q, want %q", tc.in, m.cmd, tc.wantCmd)
|
|
||||||
}
|
|
||||||
if len(m.args) != len(tc.wantArgs) {
|
|
||||||
t.Fatalf("NewMarvin(%q).args = %v, want %v", tc.in, m.args, tc.wantArgs)
|
|
||||||
}
|
|
||||||
for i := range m.args {
|
|
||||||
if m.args[i] != tc.wantArgs[i] {
|
|
||||||
t.Errorf("NewMarvin(%q).args[%d] = %q, want %q", tc.in, i, m.args[i], tc.wantArgs[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTodayUsesRunnerOutput(t *testing.T) {
|
|
||||||
m := NewMarvin("am")
|
|
||||||
var gotName string
|
|
||||||
var gotArgs []string
|
|
||||||
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
||||||
gotName, gotArgs = name, args
|
|
||||||
return []byte(`[{"id":"x","title":"Do thing","day":"2026-05-31","done":false}]`), nil
|
|
||||||
}
|
|
||||||
got, err := m.Today(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Today: %v", err)
|
|
||||||
}
|
|
||||||
if gotName != "am" || len(gotArgs) != 1 || gotArgs[0] != "--json" {
|
|
||||||
t.Fatalf("runner called with %q %v", gotName, gotArgs)
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].Title != "Do thing" {
|
|
||||||
t.Fatalf("Today result = %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTodayPropagatesError(t *testing.T) {
|
|
||||||
m := NewMarvin("am")
|
|
||||||
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
||||||
return nil, errors.New("am not found")
|
|
||||||
}
|
|
||||||
if _, err := m.Today(context.Background()); err == nil {
|
|
||||||
t.Fatal("want error from failing runner")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `go test ./internal/tasks/ -v`
|
|
||||||
Expected: FAIL — `undefined: NewMarvin`, `undefined: parse` (build error).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Write the adapter implementation**
|
|
||||||
|
|
||||||
Create `internal/tasks/marvin.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package tasks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runner executes a command and returns its stdout. Production shells out;
|
|
||||||
// tests inject a fake to avoid spawning a process.
|
|
||||||
type runner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
||||||
|
|
||||||
func execRunner(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
|
||||||
var out, errb bytes.Buffer
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = &errb
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
if s := strings.TrimSpace(errb.String()); s != "" {
|
|
||||||
return nil, fmt.Errorf("%s: %w: %s", name, err, s)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("%s: %w", name, err)
|
|
||||||
}
|
|
||||||
return out.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marvin is the Amazing Marvin adapter. It shells out to ampy's CLI
|
|
||||||
// (`am --json`, no subcommand) and parses the open tasks due today or earlier.
|
|
||||||
type Marvin struct {
|
|
||||||
cmd string
|
|
||||||
args []string
|
|
||||||
run runner
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMarvin builds the adapter. command is split on spaces so both "am" and
|
|
||||||
// "uv run am" work; empty defaults to "am". The "--json" flag is always
|
|
||||||
// appended, and no subcommand is passed (ampy lists today's tasks by default).
|
|
||||||
func NewMarvin(command string) *Marvin {
|
|
||||||
fields := strings.Fields(command)
|
|
||||||
if len(fields) == 0 {
|
|
||||||
fields = []string{"am"}
|
|
||||||
}
|
|
||||||
args := append([]string{}, fields[1:]...)
|
|
||||||
args = append(args, "--json")
|
|
||||||
return &Marvin{cmd: fields[0], args: args, run: execRunner}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Today returns the open tasks due today or earlier, or an error if the CLI
|
|
||||||
// fails. Callers degrade gracefully on error.
|
|
||||||
func (m *Marvin) Today(ctx context.Context) ([]Task, error) {
|
|
||||||
out, err := m.run(ctx, m.cmd, m.args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parse(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
type rawTask struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Day string `json:"day"`
|
|
||||||
Done bool `json:"done"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse maps `am --json` output (a JSON array of task objects) to []Task. It
|
|
||||||
// drops tasks that are done or have an empty title. A JSON null or empty array
|
|
||||||
// yields an empty slice, not an error.
|
|
||||||
func parse(data []byte) ([]Task, error) {
|
|
||||||
var raw []rawTask
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return nil, fmt.Errorf("tasks: parse: %w", err)
|
|
||||||
}
|
|
||||||
out := make([]Task, 0, len(raw))
|
|
||||||
for _, r := range raw {
|
|
||||||
title := strings.TrimSpace(r.Title)
|
|
||||||
if r.Done || title == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, Task{ID: r.ID, Title: title, Day: r.Day})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `go test ./internal/tasks/ -v`
|
|
||||||
Expected: PASS — `TestParse`, `TestNewMarvinCommandSplitting`, `TestTodayUsesRunnerOutput`, `TestTodayPropagatesError`.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/tasks/tasks.go internal/tasks/marvin.go internal/tasks/marvin_test.go
|
|
||||||
git commit -m "$(cat <<'EOF'
|
|
||||||
Add tasks port and Amazing Marvin adapter
|
|
||||||
|
|
||||||
The tasks.Provider port answers "what should I be doing?". The Marvin
|
|
||||||
adapter shells out to ampy's `am --json` and parses today's open tasks,
|
|
||||||
dropping done/empty-title entries. Leaf package mirroring ai.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Controller wiring (async fetch + projection)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/session/session.go`
|
|
||||||
- Test: `internal/session/session_test.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing controller tests**
|
|
||||||
|
|
||||||
Append to `internal/session/session_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type fakeProvider struct {
|
|
||||||
list []tasks.Task
|
|
||||||
err error
|
|
||||||
gate chan struct{} // if non-nil, Today blocks until it receives
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
|
||||||
if f.gate != nil {
|
|
||||||
<-f.gate
|
|
||||||
}
|
|
||||||
return f.list, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
|
|
||||||
func waitTasksStatus(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.Tasks != nil && st.Tasks.Status == want {
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks)
|
|
||||||
return State{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnterPlanningFetchesTasks(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}})
|
|
||||||
if err := c.EnterPlanning(); err != nil {
|
|
||||||
t.Fatalf("enter planning: %v", err)
|
|
||||||
}
|
|
||||||
st := waitTasksStatus(t, c, "ready")
|
|
||||||
if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" {
|
|
||||||
t.Fatalf("tasks list wrong: %+v", st.Tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTasksFetchError(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
c.SetTasks(&fakeProvider{err: errors.New("am down")})
|
|
||||||
if err := c.EnterPlanning(); err != nil {
|
|
||||||
t.Fatalf("enter planning: %v", err)
|
|
||||||
}
|
|
||||||
st := waitTasksStatus(t, c, "error")
|
|
||||||
if len(st.Tasks.Tasks) != 0 {
|
|
||||||
t.Fatalf("error state should carry no tasks: %+v", st.Tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoProviderNoTasksView(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
if err := c.EnterPlanning(); err != nil {
|
|
||||||
t.Fatalf("enter planning: %v", err)
|
|
||||||
}
|
|
||||||
if c.State().Tasks != nil {
|
|
||||||
t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTasksViewAbsentOutsidePlanning(t *testing.T) {
|
|
||||||
c, _ := newTestController(t)
|
|
||||||
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}})
|
|
||||||
if c.State().Tasks != nil {
|
|
||||||
t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v`
|
|
||||||
Expected: FAIL — `c.SetTasks undefined` and `st.Tasks undefined` (build error).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the import and constants**
|
|
||||||
|
|
||||||
In `internal/session/session.go`, add `"antidrift/internal/tasks"` to the import block (it already imports `"antidrift/internal/ai"`).
|
|
||||||
|
|
||||||
Immediately after the existing coach constants block (the `const ( coachIdle = "idle" … )` group near the top of the file), add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
const tasksTimeout = 30 * time.Second
|
|
||||||
|
|
||||||
const (
|
|
||||||
tasksIdle = "idle"
|
|
||||||
tasksPending = "pending"
|
|
||||||
tasksReady = "ready"
|
|
||||||
tasksError = "error"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add the controller fields**
|
|
||||||
|
|
||||||
In the `Controller` struct, immediately after the coach fields (`coachGen int`), add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
tasksProvider tasks.Provider
|
|
||||||
tasksStatus string
|
|
||||||
tasksList []tasks.Task
|
|
||||||
tasksGen int
|
|
||||||
```
|
|
||||||
|
|
||||||
(Field is named `tasksProvider`, not `tasks`, so it does not shadow the `tasks` package.)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Add the view types**
|
|
||||||
|
|
||||||
Immediately after the `CoachView` struct definition, add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In the `State` struct, add a field immediately after `Coach *CoachView … `:
|
|
||||||
|
|
||||||
```go
|
|
||||||
Tasks *TasksView `json:"tasks,omitempty"`
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add the planning-only projection**
|
|
||||||
|
|
||||||
In `stateLocked`, inside the existing `if c.runtimeState == domain.RuntimePlanning {` block, immediately after `st.Coach = cv`, add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Add SetTasks and the async fetch**
|
|
||||||
|
|
||||||
Immediately after `resetCoachLocked` (so the tasks helpers sit next to the coach helpers), add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
|
|
||||||
// the planning tasks list absent.
|
|
||||||
func (c *Controller) 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 *Controller) startTasksFetchLocked() {
|
|
||||||
c.tasksList = nil
|
|
||||||
if c.tasksProvider == nil {
|
|
||||||
c.tasksStatus = tasksIdle
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.tasksGen++
|
|
||||||
gen := c.tasksGen
|
|
||||||
c.tasksStatus = tasksPending
|
|
||||||
provider := c.tasksProvider
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout)
|
|
||||||
defer cancel()
|
|
||||||
list, err := provider.Today(ctx)
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return // stale or left planning: discard
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
c.tasksStatus = tasksError
|
|
||||||
c.tasksList = nil
|
|
||||||
} else {
|
|
||||||
c.tasksStatus = tasksReady
|
|
||||||
c.tasksList = list
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.notify()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(`context` is already imported in `session.go`.)
|
|
||||||
|
|
||||||
- [ ] **Step 8: Hook the fetch into EnterPlanning**
|
|
||||||
|
|
||||||
In `EnterPlanning`, add `c.startTasksFetchLocked()` immediately after the existing `c.resetCoachLocked()` line:
|
|
||||||
|
|
||||||
```go
|
|
||||||
c.runtimeState = next
|
|
||||||
c.resetCoachLocked()
|
|
||||||
c.startTasksFetchLocked()
|
|
||||||
return c.persistLocked()
|
|
||||||
```
|
|
||||||
|
|
||||||
(Launching the goroutine while holding `mu` is safe: its first action is the `Today()` call, so it only blocks on the lock after `EnterPlanning` has returned and released it.)
|
|
||||||
|
|
||||||
- [ ] **Step 9: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v`
|
|
||||||
Expected: PASS — `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`.
|
|
||||||
|
|
||||||
- [ ] **Step 10: Run the full session suite (no regressions, race-clean)**
|
|
||||||
|
|
||||||
Run: `go test -race ./internal/session/`
|
|
||||||
Expected: PASS (existing coach/drift/nudge tests unaffected — `EnterPlanning` with no provider just sets the idle status).
|
|
||||||
|
|
||||||
- [ ] **Step 11: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/session/session.go internal/session/session_test.go
|
|
||||||
git commit -m "$(cat <<'EOF'
|
|
||||||
Fetch today's tasks asynchronously on entering planning
|
|
||||||
|
|
||||||
The controller fetches the Marvin task list when entering planning,
|
|
||||||
mirroring the planning coach: generation-guarded goroutine, status enum
|
|
||||||
(idle/pending/ready/error), projected into State only while planning and
|
|
||||||
only when a provider is set. nil provider degrades to no tasks view.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Daemon wiring + web payload test
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `cmd/antidriftd/main.go`
|
|
||||||
- Test: `internal/web/web_test.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing web test**
|
|
||||||
|
|
||||||
Append to `internal/web/web_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type stubProvider struct {
|
|
||||||
list []tasks.Task
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
|
||||||
return s.list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
|
|
||||||
r := s.Router()
|
|
||||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("/planning code %d", w.Code)
|
|
||||||
}
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
js := s.stateJSON()
|
|
||||||
if !strings.Contains(js, `"tasks"`) {
|
|
||||||
t.Fatalf("planning payload missing tasks object: %s", js)
|
|
||||||
}
|
|
||||||
if !strings.Contains(js, `"title":"Write the spec"`) {
|
|
||||||
t.Fatalf("planning payload missing task title: %s", js)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v`
|
|
||||||
Expected: FAIL — `undefined: tasks` / `s.ctrl.SetTasks undefined` until the import resolves and the controller exposes the method (the method exists from Task 2, so the only failure here would be a missing import; if Task 2 is complete this compiles and the assertion drives correctness).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Wire the adapter into the daemon**
|
|
||||||
|
|
||||||
In `cmd/antidriftd/main.go`, add `"antidrift/internal/tasks"` to the import block. Then, immediately after the AI-wiring block (after the `if backend, err := ai.NewBackend(...) { … } else { … }` block closes), add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
|
|
||||||
// overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute
|
|
||||||
// path). A missing or failing CLI degrades to no tasks panel at fetch time —
|
|
||||||
// planning still works.
|
|
||||||
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
|
|
||||||
log.Printf("tasks: marvin adapter (am)")
|
|
||||||
```
|
|
||||||
|
|
||||||
(`os` and `log` are already imported in `main.go`.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the web test to verify it passes**
|
|
||||||
|
|
||||||
Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify the daemon builds**
|
|
||||||
|
|
||||||
Run: `go build ./cmd/antidriftd/`
|
|
||||||
Expected: builds with no output.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add cmd/antidriftd/main.go internal/web/web_test.go
|
|
||||||
git commit -m "$(cat <<'EOF'
|
|
||||||
Wire Marvin tasks adapter into the daemon
|
|
||||||
|
|
||||||
main constructs the Marvin adapter (command overridable via
|
|
||||||
ANTIDRIFT_MARVIN_CMD) and injects it. Adds a web test asserting the
|
|
||||||
planning state payload carries today's tasks.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Planning UI — today's tasks as seed chips
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/web/static/app.js`
|
|
||||||
- Modify: `internal/web/static/app.css`
|
|
||||||
|
|
||||||
This task is presentational; the data path is already covered by the Task 3 web test. There is no JS test harness in this project (consistent with M4), so verify by build/test plus the manual checklist below.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the `updatePlanningTasks` function**
|
|
||||||
|
|
||||||
In `internal/web/static/app.js`, immediately after the `updatePlanningCoach` function definition (before `function render(state)`), add:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// updatePlanningTasks renders today's Marvin tasks as clickable seed chips.
|
|
||||||
// Clicking a chip fills the intent field; the coach pipeline is unchanged.
|
|
||||||
// idle/error/empty render nothing; pending shows a quiet loading line.
|
|
||||||
function updatePlanningTasks(tasks) {
|
|
||||||
const el = document.getElementById('tasksBand');
|
|
||||||
if (!el) return;
|
|
||||||
if (!tasks || tasks.status === 'idle' || tasks.status === 'error') { el.innerHTML = ''; return; }
|
|
||||||
if (tasks.status === 'pending') { el.innerHTML = `<div class="meta">loading tasks…</div>`; return; }
|
|
||||||
const list = tasks.tasks || [];
|
|
||||||
if (!list.length) { el.innerHTML = ''; return; }
|
|
||||||
const chips = list.map((t, i) =>
|
|
||||||
`<button type="button" class="task-chip" data-i="${i}">${t.title}</button>`).join('');
|
|
||||||
el.innerHTML = `<label>Today</label><div class="tasklist">${chips}</div>`;
|
|
||||||
el.querySelectorAll('.task-chip').forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const intent = document.getElementById('intent');
|
|
||||||
if (intent) { intent.value = list[+btn.dataset.i].title; intent.focus(); }
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(Task titles are interpolated into `innerHTML` exactly as the existing coach/evidence rendering does. The titles originate from the user's own Marvin database; this carries the same pre-existing self-XSS posture as the rest of the UI and introduces no new untrusted source — out of scope to change here.)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the tasks band to the planning render**
|
|
||||||
|
|
||||||
In the planning branch of `render` (`} else if (rs === 'planning') {`), add a tasks band placeholder between the intent band and the "Next action" band. Change:
|
|
||||||
|
|
||||||
```js
|
|
||||||
<div class="band">
|
|
||||||
<label>Rough intent</label>
|
|
||||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
|
||||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
|
||||||
<div id="coachStatus" class="meta"></div>
|
|
||||||
</div>
|
|
||||||
<div class="band">
|
|
||||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```js
|
|
||||||
<div class="band">
|
|
||||||
<label>Rough intent</label>
|
|
||||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
|
||||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
|
||||||
<div id="coachStatus" class="meta"></div>
|
|
||||||
</div>
|
|
||||||
<div class="band" id="tasksBand"></div>
|
|
||||||
<div class="band">
|
|
||||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Call `updatePlanningTasks` from both render paths**
|
|
||||||
|
|
||||||
In the planning partial-update early-return at the top of `render`, change:
|
|
||||||
|
|
||||||
```js
|
|
||||||
if (rs === 'planning' && renderedState === 'planning') {
|
|
||||||
updatePlanningCoach(state.coach);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```js
|
|
||||||
if (rs === 'planning' && renderedState === 'planning') {
|
|
||||||
updatePlanningCoach(state.coach);
|
|
||||||
updatePlanningTasks(state.tasks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And at the end of the full planning render branch, change the trailing:
|
|
||||||
|
|
||||||
```js
|
|
||||||
updatePlanningCoach(state.coach);
|
|
||||||
|
|
||||||
} else if (rs === 'active') {
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```js
|
|
||||||
updatePlanningCoach(state.coach);
|
|
||||||
updatePlanningTasks(state.tasks);
|
|
||||||
|
|
||||||
} else if (rs === 'active') {
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add the chip styling**
|
|
||||||
|
|
||||||
Append to `internal/web/static/app.css`:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Planning: today's tasks as clickable seed chips */
|
|
||||||
.tasklist { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
.task-chip {
|
|
||||||
padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
|
|
||||||
background: var(--bg); color: var(--ink); font: inherit; font-size: 13px;
|
|
||||||
cursor: pointer; text-align: left;
|
|
||||||
}
|
|
||||||
.task-chip:hover { border-color: var(--accent); color: var(--accent); }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify assets still serve and the full suite is race-clean**
|
|
||||||
|
|
||||||
Run: `go vet ./... && go test -race ./...`
|
|
||||||
Expected: PASS across all packages (the embedded-asset and planning-payload tests cover the wiring; JS/CSS are presentational).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Manual visual check (by eye)**
|
|
||||||
|
|
||||||
Build and run the daemon (`go run ./cmd/antidriftd/`) with `am` available, click **Start planning**, and confirm: a "Today" band shows task chips; clicking a chip fills the intent field; **Sharpen** then drives the coach as before. With `am` absent, the band stays empty and planning works normally.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/web/static/app.js internal/web/static/app.css
|
|
||||||
git commit -m "$(cat <<'EOF'
|
|
||||||
Show today's tasks as seed chips on the planning screen
|
|
||||||
|
|
||||||
The planning view renders Marvin's today list as clickable chips;
|
|
||||||
clicking one fills the intent field, feeding the existing coach flow.
|
|
||||||
idle/error/empty render nothing, pending shows a quiet loading line.
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final Verification
|
|
||||||
|
|
||||||
After all tasks:
|
|
||||||
|
|
||||||
- [ ] `go vet ./... && go test -race ./...` is clean.
|
|
||||||
- [ ] `internal/tasks` imports only `context`, `encoding/json`, and stdlib `os/exec`/`bytes`/`fmt`/`strings` — nothing from `domain`, `session`, `evidence`, or `web` (leaf package preserved).
|
|
||||||
- [ ] No new POST routes; the seed click is client-only.
|
|
||||||
- [ ] No writeback to Marvin (read-only, per spec §7).
|
|
||||||
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).
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
# M3 — Drift Interceptor — Design
|
|
||||||
|
|
||||||
Date: 2026-05-31
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
M3 makes drift *visible and interruptive* while a commitment is Active. The
|
|
||||||
daemon watches the focused window (the M1 evidence stream) and, when the user
|
|
||||||
wanders off-task, surfaces a dismissible interrupt in the active view asking
|
|
||||||
them to refocus, justify ("this is on task"), or end the session.
|
|
||||||
|
|
||||||
It uses **cheap local matching first** and the **LLM only for the ambiguous
|
|
||||||
cases**, keeping the slow CLI off the common path. This adds the second live AI
|
|
||||||
role — `JudgeDrift` — at the **cortex** layer: it judges at a decision point the
|
|
||||||
state machine exposes (an active session observing a window), but it never forces
|
|
||||||
a transition. Enforcement (minimizing/blocking) remains deferred to M8; M3's
|
|
||||||
"friction" is UI-only.
|
|
||||||
|
|
||||||
The ambient **Nudge** role is deliberately **out of scope** here; it is the fast
|
|
||||||
follow-on (M3.5).
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
**In scope (M3):**
|
|
||||||
|
|
||||||
- Local allowed-context matching ported from `legacy/src/context.rs` into the
|
|
||||||
`evidence` package (window-class + title-substring matching).
|
|
||||||
- The coach (`ai` Coach, from M2) extended to also propose **allowed window
|
|
||||||
classes**; the planning form shows them as an editable field.
|
|
||||||
- A new `DriftJudge` AI role behind a leaf-preserving interface; `Service`
|
|
||||||
implements it via the same CLI backends as M2.
|
|
||||||
- Live drift judgment wired into the `RecordWindow` hot path: debounced
|
|
||||||
(≤ 1 judgment / ~10s) and **cached per window-class**, run in a background
|
|
||||||
goroutine, surfaced over SSE.
|
|
||||||
- An override loop: "this is on task" appends the window-class to the session's
|
|
||||||
allowed-context so it matches locally thereafter.
|
|
||||||
- Active-view drift interrupt UI; `POST /refocus` and `POST /ontask` routes.
|
|
||||||
- Allowed-context persisted in the snapshot (survives restart).
|
|
||||||
|
|
||||||
**Out of scope:**
|
|
||||||
|
|
||||||
- The ambient `Nudge` role (M3.5).
|
|
||||||
- Domain/URL and command matching. `AllowedContext` keeps those fields, but the
|
|
||||||
X11 sensor only yields window class + title — no browser URLs or process args —
|
|
||||||
so M3 matches on class + title only.
|
|
||||||
- Any enforcement (window-minimize, blocking): that is M8.
|
|
||||||
- Persisting the drift *verdict*. See "Persistence" — only allowed-context is
|
|
||||||
durable; the verdict recomputes after restart.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
M3 extends the ports-and-adapters shape established in M1/M2. The `ai` package
|
|
||||||
gains a second role (`DriftJudge`) but stays a **leaf package**; the `evidence`
|
|
||||||
package gains pure matching logic; `session.Controller` orchestrates the
|
|
||||||
debounced async judgment exactly as it orchestrates the M2 coach; the browser
|
|
||||||
renders over the existing SSE stream.
|
|
||||||
|
|
||||||
### The drift pipeline (per window observation, while Active)
|
|
||||||
|
|
||||||
```
|
|
||||||
window observation ──▶ local match against allowed-context?
|
|
||||||
│
|
|
||||||
matched ───┴─── not matched
|
|
||||||
│ │
|
|
||||||
on-task debounce + per-class cache
|
|
||||||
(clear drift) │
|
|
||||||
fresh ──┴── cached
|
|
||||||
│ │
|
|
||||||
JudgeDrift (bg) use cached verdict
|
|
||||||
│
|
|
||||||
verdict on_task / drifting
|
|
||||||
│
|
|
||||||
drift state ──▶ SSE ──▶ active-view interrupt
|
|
||||||
```
|
|
||||||
|
|
||||||
A local match short-circuits to **on-task** with no LLM call. This is
|
|
||||||
authoritative: a window in an allowed class is treated as on-task even if the
|
|
||||||
user is technically idling there. Only **unmatched** windows reach the judge.
|
|
||||||
|
|
||||||
### `evidence` — local matching (ported from Rust)
|
|
||||||
|
|
||||||
New `internal/evidence/context.go`, porting `legacy/src/context.rs`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
|
||||||
// M3 uses class + title only; domains/commands are matched by the helpers but
|
|
||||||
// have no data source yet.
|
|
||||||
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool
|
|
||||||
```
|
|
||||||
|
|
||||||
with helpers `windowClassAllowed`, `windowTitleAllowed` (and `domainAllowed`,
|
|
||||||
`commandAllowed` ported for completeness/tests, unused on the live path):
|
|
||||||
|
|
||||||
- class: trimmed, casefolded, **exact** match against `WindowClasses`.
|
|
||||||
- title: trimmed, casefolded **substring** match against
|
|
||||||
`WindowTitleSubstrings`.
|
|
||||||
- domain: exact or subdomain (`docs.github.com` matches `github.com`),
|
|
||||||
trailing-dot/whitespace/case normalized.
|
|
||||||
- command: executable basename, casefolded.
|
|
||||||
|
|
||||||
`MatchesAllowed` returns true if class OR title matches. `evidence` may import
|
|
||||||
`domain` (for `AllowedContext`) — `domain` is a pure leaf, so no cycle.
|
|
||||||
|
|
||||||
### `ai` — `DriftJudge`, leaf-preserving
|
|
||||||
|
|
||||||
The M2 review established that `ai` imports nothing from the app. To keep that,
|
|
||||||
`JudgeDrift` takes **primitives**, not `domain`/`evidence` types — the controller
|
|
||||||
formats the commitment and window into strings before calling.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Verdict is the drift judge's call on a single window.
|
|
||||||
type Verdict struct {
|
|
||||||
OnTask bool
|
|
||||||
Reason string
|
|
||||||
}
|
|
||||||
|
|
||||||
// DriftJudge decides whether the current window is on-task for a commitment.
|
|
||||||
type DriftJudge interface {
|
|
||||||
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Service` (from M2) also implements `DriftJudge`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
It builds a strict-JSON prompt, calls `backend.Run`, and parses:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"on_task": false, "reason": "Reddit is unrelated to drafting the report"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `buildDriftPrompt(commitment, class, title)` — gives the model the commitment
|
|
||||||
description and the current window, asks for ONLY the JSON object above.
|
|
||||||
- `parseVerdict(s string) (Verdict, error)` — reuses `extractJSON`; unmarshals
|
|
||||||
`{on_task bool, reason string}`; trims `reason`. A missing/empty body or
|
|
||||||
unparseable output returns an error (sentinel `ErrInvalidVerdict`); the caller
|
|
||||||
degrades by leaving drift state unchanged.
|
|
||||||
|
|
||||||
### Coach extension (allowed window classes)
|
|
||||||
|
|
||||||
`ai.Proposal` gains a field; the coach prompt asks for it:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Proposal struct {
|
|
||||||
NextAction string
|
|
||||||
SuccessCondition string
|
|
||||||
TimeboxSecs int64
|
|
||||||
AllowedWindowClasses []string // NEW
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Prompt JSON shape extends to:
|
|
||||||
`{"next_action":..., "success_condition":..., "timebox_minutes":..., "allowed_window_classes":["code","firefox"]}`.
|
|
||||||
`parseProposal` reads the new array (optional — absent/empty is valid; the user
|
|
||||||
can fill it in). `ProposalView` (session) gains `allowed_window_classes`.
|
|
||||||
|
|
||||||
### `session.Controller` — orchestration
|
|
||||||
|
|
||||||
New ephemeral + durable state on the controller:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// durable (persisted): the active session's allowed classes, mutable via override
|
|
||||||
allowedClasses []string
|
|
||||||
|
|
||||||
// ephemeral drift machinery
|
|
||||||
judge ai.DriftJudge
|
|
||||||
driftStatus string // "idle" | "pending" | "ontask" | "drifting"
|
|
||||||
driftReason string
|
|
||||||
driftGen int
|
|
||||||
lastJudgedAt time.Time
|
|
||||||
judgedClasses map[string]ai.Verdict // per-class cache for this session
|
|
||||||
```
|
|
||||||
|
|
||||||
**Injection:** `SetDriftJudge(ai.DriftJudge)` (mirrors `SetCoach`). Nil judge ⇒
|
|
||||||
drift stays idle; local matching still runs (a matched window shows on-task).
|
|
||||||
|
|
||||||
**Hot-path hook** in `RecordWindow` (while holding the lock, after `applyEvent`,
|
|
||||||
only when Active):
|
|
||||||
|
|
||||||
1. Build `domain.AllowedContext{WindowClasses: c.allowedClasses}` and test
|
|
||||||
`MatchesAllowed(ac, class, title)`. On match ⇒ set `driftStatus=ontask`,
|
|
||||||
clear reason. Done (no LLM). (Only `WindowClasses` is populated in M3, since
|
|
||||||
the coach proposes classes; title substrings are matched by the helper but
|
|
||||||
left empty.)
|
|
||||||
2. Else consult per-class cache: if `judgedClasses[class]` exists ⇒ apply it
|
|
||||||
(`ontask`/`drifting` + reason). Done.
|
|
||||||
3. Else **debounce**: if `now.Sub(lastJudgedAt) < driftDebounce` (10s) ⇒ leave
|
|
||||||
current state. Done.
|
|
||||||
4. Else launch judgment: bump `driftGen`, capture `gen`, set
|
|
||||||
`lastJudgedAt=now`, `driftStatus=pending`, capture `judge` + commitment text
|
|
||||||
+ class/title, then (after unlocking, per the M2 pattern) run the judge in a
|
|
||||||
goroutine with a `driftTimeout` (30s) context.
|
|
||||||
|
|
||||||
**Goroutine completion** (re-acquire lock): discard if `gen != driftGen` or no
|
|
||||||
longer Active (stale — commitment ended/changed). Else cache
|
|
||||||
`judgedClasses[class]=verdict`; if `class` is still the current window's class,
|
|
||||||
set `driftStatus` to `ontask`/`drifting` + reason. Unlock, `notify()`.
|
|
||||||
|
|
||||||
All `notify()` calls fire with the mutex released — identical discipline to M2's
|
|
||||||
`RequestCoach` and the existing focus path.
|
|
||||||
|
|
||||||
**Override / dismiss:**
|
|
||||||
|
|
||||||
```go
|
|
||||||
// OnTask appends the current window class to the session allowed-context, clears
|
|
||||||
// drift, and persists. The class now matches locally and is never re-judged.
|
|
||||||
func (c *Controller) OnTask() error
|
|
||||||
|
|
||||||
// Refocus clears the current drift verdict without changing allowed-context.
|
|
||||||
// The same off-task class may be judged again later.
|
|
||||||
func (c *Controller) Refocus() error
|
|
||||||
```
|
|
||||||
|
|
||||||
Both return `ErrNotActive` outside the Active state. `OnTask` appends to
|
|
||||||
`allowedClasses`, drops any cached drifting verdict for that class, sets
|
|
||||||
`driftStatus=ontask`, and persists the snapshot.
|
|
||||||
|
|
||||||
**Commitment start:** `StartManualCommitment` gains an `allowedClasses []string`
|
|
||||||
parameter, stored on the controller and persisted. Drift caches/state reset when
|
|
||||||
a session starts and when it ends (`End`).
|
|
||||||
|
|
||||||
### State projection
|
|
||||||
|
|
||||||
`State` gains a drift view, projected **only while Active**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type DriftView struct {
|
|
||||||
Status string `json:"status"` // idle | pending | ontask | drifting
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
// added to State:
|
|
||||||
// Drift *DriftView `json:"drift,omitempty"`
|
|
||||||
```
|
|
||||||
|
|
||||||
`CommitmentView` is unchanged; `ProposalView` gains
|
|
||||||
`AllowedWindowClasses []string json:"allowed_window_classes,omitempty"`.
|
|
||||||
|
|
||||||
### Persistence
|
|
||||||
|
|
||||||
`store.Snapshot` gains `AllowedWindowClasses []string`. `persistLocked` writes
|
|
||||||
the controller's `allowedClasses`; `New` restores them when a live Active session
|
|
||||||
is rebuilt. **The drift verdict is NOT persisted** — on restart it recomputes
|
|
||||||
from the first post-restart window observation (≤ one debounce window). This
|
|
||||||
avoids showing a stale "drifting" interrupt for a window the user has already
|
|
||||||
navigated away from, at the cost of a brief idle state after restart. This is a
|
|
||||||
deliberate refinement of "persist session state across restart".
|
|
||||||
|
|
||||||
### `web` layer
|
|
||||||
|
|
||||||
- `commitmentRequest` gains `AllowedWindowClasses []string json:"allowed_window_classes"`;
|
|
||||||
`handleCommitment` passes them to `StartManualCommitment`.
|
|
||||||
- `POST /refocus` → `ctrl.Refocus()`; `POST /ontask` → `ctrl.OnTask()`. Both via
|
|
||||||
the existing `respond` helper (so `ErrNotActive` maps to 400, success
|
|
||||||
broadcasts).
|
|
||||||
|
|
||||||
### UI (`internal/web/static/index.html`)
|
|
||||||
|
|
||||||
**Planning view:** add an "Allowed apps" input (comma-separated window classes),
|
|
||||||
pre-filled from `coach.proposal.allowed_window_classes` when a proposal lands
|
|
||||||
(same one-time, non-clobbering pre-fill as M2). The Start commitment POST
|
|
||||||
includes the parsed list.
|
|
||||||
|
|
||||||
**Active view:** when `state.drift.status === 'drifting'`, render an interrupt
|
|
||||||
block above/around the timer:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠ Possible drift
|
|
||||||
<reason>
|
|
||||||
[ Back to task ] [ This is on task ] [ End session ]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Back to task` → `POST /refocus`
|
|
||||||
- `This is on task` → `POST /ontask`
|
|
||||||
- `End session` → the existing `POST /complete` (Active → Review), same as the
|
|
||||||
active view's current Complete button
|
|
||||||
|
|
||||||
`status === 'pending'` may show a subtle "checking…" hint; `ontask`/`idle` show
|
|
||||||
nothing. The active view already rebuilds on SSE ticks and runs a countdown
|
|
||||||
timer; the drift block must integrate without resetting the timer — apply the
|
|
||||||
same partial-update care used for the planning coach (update the drift region
|
|
||||||
without tearing down the countdown). The interrupt is **non-modal** (it cannot
|
|
||||||
lock the user out — enforcement is M8).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
No new configuration. The drift judge reuses the M2 backend selected by
|
|
||||||
`ANTIDRIFT_AI_BACKEND`; the daemon wires the single `Service` into both
|
|
||||||
`SetCoach` and `SetDriftJudge`. Debounce (10s) and timeout (30s) are constants.
|
|
||||||
|
|
||||||
## Error Handling and Degradation
|
|
||||||
|
|
||||||
| Condition | Result |
|
|
||||||
| --------- | ------ |
|
|
||||||
| Nil judge (unwired/misconfig) | Local matching still runs; unmatched windows leave drift `idle` — never blocks |
|
|
||||||
| CLI failure / timeout / unparseable verdict | Judgment discarded; drift state unchanged (no false "drifting"); logged server-side |
|
|
||||||
| Judge slow, user changes window | Per-class cache + generation guard; stale results discarded |
|
|
||||||
| `/refocus` or `/ontask` outside Active | `ErrNotActive` → HTTP 400 |
|
|
||||||
|
|
||||||
Drift judgment failures **never** fabricate a drift verdict; the safe default is
|
|
||||||
"not drifting".
|
|
||||||
|
|
||||||
## Package Layout Changes
|
|
||||||
|
|
||||||
| Package | Change |
|
|
||||||
| ------- | ------ |
|
|
||||||
| `evidence` | New `context.go` (matching helpers + `MatchesAllowed`) + tests ported from `legacy/src/context.rs` |
|
|
||||||
| `ai` | `Verdict`, `DriftJudge`, `Service.JudgeDrift`, `buildDriftPrompt`, `parseVerdict`, `ErrInvalidVerdict`; `Proposal.AllowedWindowClasses` + coach prompt/parse updates |
|
|
||||||
| `session` | `allowedClasses` (persisted), drift machinery (judge, status, reason, gen, debounce, per-class cache); `SetDriftJudge`; `RecordWindow` hook; `OnTask`/`Refocus`; `StartManualCommitment` allowed-classes param; `DriftView` + `State.Drift`; `ProposalView.AllowedWindowClasses`; snapshot field |
|
|
||||||
| `store` | `Snapshot.AllowedWindowClasses` |
|
|
||||||
| `web` | `commitmentRequest.AllowedWindowClasses`; `POST /refocus`, `POST /ontask` |
|
|
||||||
| `web/static/index.html` | planning "allowed apps" field; active-view drift interrupt; partial-update care |
|
|
||||||
| `cmd/antidriftd` | `ctrl.SetDriftJudge(service)` alongside `SetCoach` |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
**`evidence`:** port the `context.rs` test table (class exact/casefold, title
|
|
||||||
substring, domain exact/subdomain/normalization, command basename) plus
|
|
||||||
`MatchesAllowed` (class-only match, title-only match, neither).
|
|
||||||
|
|
||||||
**`ai`:** `parseVerdict` (valid on_task true/false, chatty-wrapped, missing
|
|
||||||
fields → error); `Service.JudgeDrift` over a `fakeBackend`; `parseProposal` now
|
|
||||||
reads `allowed_window_classes` (present, absent, empty); arg/prompt building does
|
|
||||||
not regress. No real CLI.
|
|
||||||
|
|
||||||
**`session`** (with a fake `DriftJudge`):
|
|
||||||
|
|
||||||
- Local match ⇒ `ontask`, judge never called.
|
|
||||||
- Unmatched ⇒ `pending` then `drifting`/`ontask` per the fake's verdict.
|
|
||||||
- Per-class cache: second observation of a judged class does not call the judge
|
|
||||||
again.
|
|
||||||
- Debounce: rapid unmatched observations within 10s trigger at most one judge
|
|
||||||
call (drive `clock` via the existing `SetClock`).
|
|
||||||
- Stale generation: slow judge result discarded after the session ends / a new
|
|
||||||
one starts (gate the fake on a channel, as in the M2 coach test).
|
|
||||||
- `OnTask` appends the class, clears drift, and a subsequent observation of that
|
|
||||||
class matches locally (no judge call); persisted across reload.
|
|
||||||
- `Refocus` clears drift without mutating allowed-context.
|
|
||||||
- Restart restores `allowedClasses` from the snapshot; drift starts `idle`.
|
|
||||||
- Nil judge: unmatched window leaves drift `idle`, no panic.
|
|
||||||
|
|
||||||
**`web`:** `/refocus` and `/ontask` happy paths + outside-Active 400; commitment
|
|
||||||
request carries allowed classes into the controller.
|
|
||||||
|
|
||||||
All tests use fakes; **no test spawns a real CLI**. `go test -race ./...` stays
|
|
||||||
clean.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- `evidence` matching ported with tests.
|
|
||||||
- `ai` `DriftJudge` + coach allowed-classes extension, with tests.
|
|
||||||
- Controller drift pipeline (local-first, debounced, cached, async) with
|
|
||||||
override/dismiss and persistence, race-clean.
|
|
||||||
- `/refocus`, `/ontask` routes; planning allowed-apps field; active-view drift
|
|
||||||
interrupt that doesn't disrupt the timer.
|
|
||||||
- Daemon wires `SetDriftJudge`.
|
|
||||||
- `go test -race ./...` and `go vet ./...` pass; manual smoke: start a session
|
|
||||||
with an allowed class, switch to an unrelated app, see the interrupt, exercise
|
|
||||||
both buttons.
|
|
||||||
- README/roadmap note M3 complete.
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
# M3.5 — Semantic Nudge — Design
|
|
||||||
|
|
||||||
Date: 2026-05-31
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
M3 makes drift visible when the user switches to the *wrong application* — a
|
|
||||||
cheap local match owns the common case and the LLM judges the ambiguous ones,
|
|
||||||
producing an interruptive banner. But that machinery is structurally blind to a
|
|
||||||
second failure mode: the user sits in an **allowed application the whole time**
|
|
||||||
and still drifts — coding the wrong project in the editor, reading tangential
|
|
||||||
docs in an allowed browser. Local match is authoritative for on-task and caches
|
|
||||||
per window-class, so once a class is allowed the user is never re-judged while
|
|
||||||
in it. They can rabbit-hole for an hour and be called perfectly on-task.
|
|
||||||
|
|
||||||
M3.5 adds the third and final advisor role, **`Nudge`**, to close that gap. It
|
|
||||||
catches *"right app, wrong work"* — **semantic** drift within allowed apps —
|
|
||||||
with a soft, ambient, periodic check-in. It is the deliberate, narrow follow-on
|
|
||||||
that completes the original M3 roadmap entry ("drift interceptor + ambient
|
|
||||||
nudge").
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
**In scope (M3.5):**
|
|
||||||
|
|
||||||
- A new `Nudge` AI role behind a leaf-preserving interface (`ai.Nudger`);
|
|
||||||
`Service` implements it via the same CLI backends as the coach and drift
|
|
||||||
judge. Signature takes primitives only: `Nudge(ctx, commitment string,
|
|
||||||
recentTitles []string) (string, error)`, returning an advisory sentence, or
|
|
||||||
`""` when the trajectory is still on-task.
|
|
||||||
- A small in-memory ring of the **last 10 distinct window titles** seen during
|
|
||||||
the active session — the trajectory signal the nudge judges against.
|
|
||||||
- The nudge wired into `evaluateDriftLocked`'s **local-match on-task branch
|
|
||||||
only**: it runs precisely where the drift judge stays silent, so the two are
|
|
||||||
mutually exclusive per observation and never overlap.
|
|
||||||
- Debounced to at most one nudge per `nudgeDebounce` (5 min); run in a
|
|
||||||
background goroutine; gated to Active sessions with ≥ 2 titles of history.
|
|
||||||
- Surfaced over SSE as a new `DriftView.Nudge` field; the active view renders a
|
|
||||||
visually distinct **soft "Heads up" tier** on the existing drift banner —
|
|
||||||
dismiss-only, no action buttons.
|
|
||||||
- Graceful degradation: a nil nudger leaves the system silent; everything else
|
|
||||||
works unchanged.
|
|
||||||
|
|
||||||
**Out of scope:**
|
|
||||||
|
|
||||||
- Any enforcement: the nudge informs and never forces — same as the drift
|
|
||||||
interceptor. Enforcement remains M8.
|
|
||||||
- Persisting the nudge message (ephemeral, like the drift verdict — recomputes
|
|
||||||
after restart).
|
|
||||||
- A server-side dismiss route: dismissal is client-side, keyed on the message
|
|
||||||
text, and auto-clears when the trajectory recovers or the session changes.
|
|
||||||
- Re-running the nudge on the *cached-on-task* or *judged-on-task* paths. The
|
|
||||||
nudge is gated strictly to the local-match-authoritative on-task path. (Once
|
|
||||||
"This is on task" appends a class to the allowed list, it becomes a local
|
|
||||||
match thereafter, so the realistic "I'm in my app" case is covered.)
|
|
||||||
- Changing the hard local rules. "Outlook during a focus session = violation"
|
|
||||||
stays exactly where it is in the interceptor; the nudge is purely the soft
|
|
||||||
semantic layer above it.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
M3.5 extends the ports-and-adapters shape of M1/M2/M3 without adding any new
|
|
||||||
infrastructure. The `ai` package gains a third role (`Nudger`) but stays a
|
|
||||||
**leaf package**: like `Coach` and `DriftJudge`, `Nudger` takes primitive
|
|
||||||
strings, not `domain`/`evidence` types. `session.Controller` orchestrates the
|
|
||||||
debounced async nudge with the *exact same discipline* as the M3 drift judge —
|
|
||||||
debounce, on-task-stretch epoch guard, goroutine launched after releasing the
|
|
||||||
mutex, `notify()` only with the mutex released, never fabricate on error.
|
|
||||||
|
|
||||||
### The `ai.Nudger` role
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Nudger judges whether recent activity within an allowed app still serves the
|
|
||||||
// commitment. 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)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Service.Nudge` runs `buildNudgePrompt(commitment, recentTitles)` on the
|
|
||||||
backend and parses the result. The prompt asks for exactly:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"on_track": <true or false>, "message": "<one short sentence>"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Parsing mirrors `parseVerdict` and reuses the shared `extractJSON` /
|
|
||||||
`ErrEmptyResponse` / `ErrNoJSON` helpers:
|
|
||||||
|
|
||||||
- `on_track: true` → return `""` (nothing to surface).
|
|
||||||
- `on_track: false` with a non-empty `message` → return the trimmed message.
|
|
||||||
- `on_track: false` with **no** message → treat as on-track (return `""`). A
|
|
||||||
concern with no words is unusable and would only add noise, so the parser
|
|
||||||
swallows it rather than erroring. (This differs from `parseVerdict`, which
|
|
||||||
rejects a reasonless drift as `ErrInvalidVerdict` because the drift banner is
|
|
||||||
interruptive; the nudge is ambient and silent-by-default, so the safe
|
|
||||||
degenerate is silence, not an error.)
|
|
||||||
- Empty / no-JSON / malformed output → the corresponding error, surfaced to the
|
|
||||||
caller, which logs and stays silent.
|
|
||||||
|
|
||||||
### Controller state
|
|
||||||
|
|
||||||
New fields on `Controller` (all reset per session in `resetDriftLocked`):
|
|
||||||
|
|
||||||
- `nudge ai.Nudger` — the injected judge; nil disables nudging.
|
|
||||||
- `recentTitles []string` — ring of the last 10 distinct titles this session.
|
|
||||||
- `nudgeMessage string` — the current soft advisory ("" = none).
|
|
||||||
- `lastNudgedAt time.Time` — debounce timestamp.
|
|
||||||
|
|
||||||
A soft nudge advisory belongs to one continuous on-task stretch in an allowed
|
|
||||||
app, so the nudge is guarded by an on-task-stretch epoch (`nudgeEpoch`) rather
|
|
||||||
than `driftGen` (which bumps on every drift-judgment launch). `nudgeEpoch`
|
|
||||||
advances on session reset (`resetDriftLocked`) and whenever an observation is
|
|
||||||
not a local on-task match — i.e. the stretch ended. A nudge captures the epoch
|
|
||||||
at launch and applies its result only if the epoch is unchanged, so a nudge
|
|
||||||
whose stretch has since ended (a drift episode or a session change) is discarded
|
|
||||||
instead of surfacing stale. Leaving the allowed app additionally clears any
|
|
||||||
already-set advisory. The net effect: the advisory auto-clears when the
|
|
||||||
trajectory changes or recovers, and a stale "Heads up" never resurfaces after a
|
|
||||||
drift episode.
|
|
||||||
|
|
||||||
New constants alongside the drift ones:
|
|
||||||
|
|
||||||
```go
|
|
||||||
nudgeDebounce = 5 * time.Minute
|
|
||||||
nudgeTimeout = 30 * time.Second
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data flow
|
|
||||||
|
|
||||||
`RecordWindow` is unchanged in shape: it still captures a single `launch func()`
|
|
||||||
from `evaluateDriftLocked` and runs it via `go launch()` after unlocking. The
|
|
||||||
drift judge and the nudge are **mutually exclusive** per observation —
|
|
||||||
matched → maybe-nudge; unmatched → maybe-judge — so one closure return covers
|
|
||||||
both.
|
|
||||||
|
|
||||||
1. **Recent-titles ring.** While Active, `RecordWindow` appends the observed
|
|
||||||
title to `recentTitles` when it is non-empty and differs from the most recent
|
|
||||||
entry, capping the slice at 10 (drop oldest). This happens before drift
|
|
||||||
evaluation so the latest title is in view.
|
|
||||||
2. **Nudge branch.** In `evaluateDriftLocked` step 1, when `MatchesAllowed`
|
|
||||||
returns true, the controller sets `driftOnTask` as today and then evaluates
|
|
||||||
nudge eligibility:
|
|
||||||
- `c.nudge != nil`, runtime is Active, `len(c.recentTitles) >= 2`, and
|
|
||||||
`lastNudgedAt` is zero or `now.Sub(lastNudgedAt) >= nudgeDebounce`.
|
|
||||||
- If eligible: stamp `lastNudgedAt = now`, capture `epoch := c.nudgeEpoch`,
|
|
||||||
the commitment string (same `NextAction — SuccessCondition` form as the
|
|
||||||
drift judge), and a **copy** of `recentTitles`; return the nudge closure.
|
|
||||||
- If not eligible: return `nil` (unchanged behavior).
|
|
||||||
3. **Nudge closure** (runs in the goroutine):
|
|
||||||
- Calls `nudge.Nudge(ctx, commitment, titles)` under a `nudgeTimeout`
|
|
||||||
context.
|
|
||||||
- Re-acquires the lock; if `epoch != c.nudgeEpoch || c.runtimeState !=
|
|
||||||
RuntimeActive` → stale, return.
|
|
||||||
- On error → log, leave `nudgeMessage` unchanged (no fabrication), unlock,
|
|
||||||
return. (`lastNudgedAt` stays set, so a failed call does not immediately
|
|
||||||
retry.)
|
|
||||||
- On success → set `c.nudgeMessage = msg` (which may be `""`, clearing a
|
|
||||||
prior advisory once the trajectory recovers); unlock; `notify()`.
|
|
||||||
|
|
||||||
### Surfacing
|
|
||||||
|
|
||||||
`DriftView` gains one field:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type DriftView struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
Nudge string `json:"nudge,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The nudge is a **separate axis** from `Status`: it is populated precisely when
|
|
||||||
`Status == "ontask"`, so it cannot be folded into the status enum.
|
|
||||||
`stateLocked` sets `Nudge: c.nudgeMessage` whenever it builds the `DriftView`.
|
|
||||||
|
|
||||||
In `index.html`, `updateActiveDrift(drift)` renders, in priority order:
|
|
||||||
|
|
||||||
- `drift.status === "drifting"` → the existing hard banner (Back to task / This
|
|
||||||
is on task / End session). Unchanged.
|
|
||||||
- else if `drift.nudge` is non-empty → a **soft "Heads up" tier**: muted
|
|
||||||
styling clearly lower-stakes than the hard banner, the message text, and a
|
|
||||||
single **Dismiss** control. No Refocus/OnTask/Complete buttons.
|
|
||||||
- else → clear the region.
|
|
||||||
|
|
||||||
**Client-side dismiss.** Dismiss hides the soft tier and remembers the dismissed
|
|
||||||
message text in a module-level variable; the renderer skips re-showing that
|
|
||||||
exact text. When the nudge message changes (a new concern) or clears and later
|
|
||||||
returns, it shows again. No server round-trip, no new route.
|
|
||||||
|
|
||||||
### Wiring
|
|
||||||
|
|
||||||
`cmd/antidriftd/main.go`: the single `ai.Service` already satisfies `Coach` and
|
|
||||||
`DriftJudge`; add `ctrl.SetNudge(svc)` and update the startup log line to note
|
|
||||||
the third role.
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
Nothing new is persisted. `recentTitles`, `nudgeMessage`, and `lastNudgedAt` are
|
|
||||||
all in-memory session state, cleared by `resetDriftLocked` on session start and
|
|
||||||
on the Active-restore path (the same place that already resets drift state to
|
|
||||||
avoid stale interrupts after a restart).
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- Backend/parse failure → logged, `nudgeMessage` untouched, system silent. The
|
|
||||||
nudge never fabricates a concern, mirroring the drift judge's "never fabricate
|
|
||||||
drift" rule.
|
|
||||||
- A reasonless concern from the model degrades to silence (see parsing).
|
|
||||||
- Stale results (the on-task stretch ended via a drift episode, or the session
|
|
||||||
ended/restarted mid-call) are discarded by the `nudgeEpoch` guard.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**`internal/ai/nudge_test.go`** (stdlib `testing`, table-driven like
|
|
||||||
`verdict_test.go`):
|
|
||||||
|
|
||||||
- `on_track: true` → `""`, nil error.
|
|
||||||
- `on_track: false` + message → trimmed message.
|
|
||||||
- `on_track: false` + empty message → `""` (tolerant degrade).
|
|
||||||
- empty output → `ErrEmptyResponse`; no-brace output → `ErrNoJSON`; malformed
|
|
||||||
JSON → wrapped parse error.
|
|
||||||
- JSON embedded in surrounding prose → extracted (exercises `extractJSON`
|
|
||||||
reuse).
|
|
||||||
|
|
||||||
**`internal/session/session_test.go`** (extend, reuse the `fakeJudge`-style
|
|
||||||
harness with a `fakeNudger`: configurable message/err, an optional gate channel,
|
|
||||||
an atomic call counter):
|
|
||||||
|
|
||||||
- Nudge fires on the local-match on-task path once history ≥ 2 and debounce has
|
|
||||||
elapsed; sets `DriftView.Nudge`.
|
|
||||||
- Nudge does **not** fire on the unmatched (drift-judge) path — verify the
|
|
||||||
nudger sees zero calls when the window is off-app.
|
|
||||||
- Debounce limits nudges to one per `nudgeDebounce` (drive with `SetClock`).
|
|
||||||
- A nudger error leaves `nudgeMessage` empty (no fabrication) and does not crash.
|
|
||||||
- An `on_track` result clears a previously set `nudgeMessage`.
|
|
||||||
- A stale nudge (session ended before the call returns) is discarded — message
|
|
||||||
not applied.
|
|
||||||
- A nil nudger leaves the on-task path silent (no nudge, no panic).
|
|
||||||
- `recentTitles` records distinct titles and caps at 10.
|
|
||||||
|
|
||||||
**`internal/web/web_test.go`**: assert the state JSON carries the `nudge` field
|
|
||||||
when set (extend an existing active-state test rather than adding a route test —
|
|
||||||
there is no new route).
|
|
||||||
|
|
||||||
All tests pass under `go test -race ./...`; `go vet ./...` clean.
|
|
||||||
|
|
||||||
## File structure
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `internal/ai/nudge.go` | new: `Nudger` interface, `Service.Nudge`, `buildNudgePrompt`, `parseNudge` |
|
|
||||||
| `internal/ai/nudge_test.go` | new: parse/role tests |
|
|
||||||
| `internal/session/session.go` | nudge fields, constants, `recentTitles` ring in `RecordWindow`, nudge branch + closure in `evaluateDriftLocked`, `SetNudge`, `resetDriftLocked` additions, `DriftView.Nudge`, `stateLocked` wiring |
|
|
||||||
| `internal/session/session_test.go` | `fakeNudger` + nudge tests |
|
|
||||||
| `internal/web/web.go` | (only if a struct/tag touch is needed; likely none — `DriftView` is in `session`) |
|
|
||||||
| `internal/web/web_test.go` | assert `nudge` in state JSON |
|
|
||||||
| `internal/web/static/index.html` | soft "Heads up" tier in `updateActiveDrift` + CSS; client-side dismiss |
|
|
||||||
| `cmd/antidriftd/main.go` | `ctrl.SetNudge(svc)` + log line |
|
|
||||||
| `README.md` | M3.5 paragraph in Status |
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# M4 — "Look good" Design
|
|
||||||
|
|
||||||
**Goal:** A real design pass on the web UI: a cockpit-style, state-aware HUD that
|
|
||||||
reads at a glance, with CSS/JS split out of the inline HTML for maintainability,
|
|
||||||
and a polished review recap. No behavior changes.
|
|
||||||
|
|
||||||
**Status:** Design approved 2026-05-31. Supersedes the utilitarian inline UI
|
|
||||||
shipped through M3.5.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Direction & Visual System
|
|
||||||
|
|
||||||
The UI is an **instrument panel you glance at** — a cockpit, not a document.
|
|
||||||
Dark, near-black cool-neutral base. State is carried by a **single state-driven
|
|
||||||
accent**: a CSS custom property `--accent` switched by a `data-state` attribute
|
|
||||||
on the `<main>` element. The accent colors the status band's top border and the
|
|
||||||
state pill, so the frame itself communicates where you are without reading text.
|
|
||||||
|
|
||||||
### State → accent mapping
|
|
||||||
|
|
||||||
| State | `data-state` | Accent |
|
|
||||||
|--------------------|--------------|--------------------|
|
|
||||||
| locked | `locked` | dim gray |
|
|
||||||
| planning | `planning` | blue |
|
|
||||||
| active · on-task | `active` | calm green / cyan |
|
|
||||||
| active · nudge | `nudge` | amber |
|
|
||||||
| active · drifting | `drift` | red |
|
|
||||||
| review | `review` | violet-neutral |
|
|
||||||
|
|
||||||
`data-state` is derived in the client render from `runtime_state` plus, when
|
|
||||||
active, the drift sub-status (`drifting` → `drift`; a present `nudge` → `nudge`;
|
|
||||||
otherwise `active`). This mirrors the precedence already used by the status-file
|
|
||||||
renderer (drift outranks nudge).
|
|
||||||
|
|
||||||
### Design tokens (CSS custom properties)
|
|
||||||
|
|
||||||
- Surfaces / text: `--bg`, `--panel`, `--line`, `--ink`, `--ink-dim`
|
|
||||||
- State accent: `--accent` (the only variable that changes with `data-state`)
|
|
||||||
- Fixed semantic colors: `--ok`, `--warn`, `--danger`
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
- Timer: heavy weight, `font-variant-numeric: tabular-nums`.
|
|
||||||
- Evidence times: `ui-monospace` so the bucket columns align.
|
|
||||||
- Band headers / pills: small, uppercase, letter-spaced (keeps the existing pill
|
|
||||||
idiom from the current UI).
|
|
||||||
- Prose: `system-ui`.
|
|
||||||
|
|
||||||
## 2. Layout — Stacked HUD Bands
|
|
||||||
|
|
||||||
Every state composes the same **band primitive**: a row with a top divider and
|
|
||||||
consistent horizontal/vertical padding. Stacking bands produces the layered HUD
|
|
||||||
look. The active session follows the approved sketch:
|
|
||||||
|
|
||||||
```
|
|
||||||
ACTIVE · on task · 7 switches ← status band (accent border-top + pill)
|
|
||||||
24:18 write the spec section ← timer band
|
|
||||||
done when: draft saved ← task band
|
|
||||||
now code·spec ● | code 18:02 … ← evidence band
|
|
||||||
[ Complete ] ← action band
|
|
||||||
```
|
|
||||||
|
|
||||||
Drift and nudge are **not** a separate floating box. When the session drifts or
|
|
||||||
is nudged, the **status band itself** changes copy and `data-state` flips, so the
|
|
||||||
whole frame goes amber/red. The same controls render inside that band:
|
|
||||||
|
|
||||||
- Drift: `Back to task` (`/refocus`), `This is on task` (`/ontask`),
|
|
||||||
`End session` (`/complete`).
|
|
||||||
- Nudge: `Dismiss` (client-only, current behavior).
|
|
||||||
- Pending: a quiet "checking focus…" line.
|
|
||||||
|
|
||||||
## 3. Per-State Treatment
|
|
||||||
|
|
||||||
- **Locked:** one dim band, large `Start planning` button (`/planning`).
|
|
||||||
- **Planning:** an intent + `Sharpen` band, then field bands — Next action,
|
|
||||||
Success condition, Minutes, Allowed apps — with the blue accent. All existing
|
|
||||||
input ids (`#intent`, `#na`, `#sc`, `#mins`, `#apps`, `#start`,
|
|
||||||
`#coachStatus`) and the coach pre-fill behavior are untouched.
|
|
||||||
- **Active:** the HUD described in §2.
|
|
||||||
- **Review (polished, presentational only):** summary bands built from data the
|
|
||||||
state already carries — `next_action`, `success_condition`, the context-switch
|
|
||||||
count, and the per-window bucket recap (reusing the existing `evidence`
|
|
||||||
fields). **No new backend data** is introduced; richer session reflection is
|
|
||||||
M7's job. The `End` button (`/end`) remains.
|
|
||||||
|
|
||||||
## 4. Structure
|
|
||||||
|
|
||||||
Split the single inline file into three files under `internal/web/static/`:
|
|
||||||
|
|
||||||
- `index.html` — markup shell only (`<head>` links the stylesheet and script).
|
|
||||||
- `app.css` — the full visual system (tokens, bands, per-state rules).
|
|
||||||
- `app.js` — the render logic, **moved verbatim**: same `render()` function,
|
|
||||||
same partial-update paths (`updateActiveDrift`, `updatePlanningCoach`), same
|
|
||||||
element ids, same `EventSource('/events')` and POST endpoints. The only
|
|
||||||
additions are the band markup in the template strings and setting
|
|
||||||
`main.dataset.state` per render.
|
|
||||||
|
|
||||||
`web.go` currently serves only `/` via `c.FileFromFS`. Add routes so the two new
|
|
||||||
assets are served from the embedded `staticFS`:
|
|
||||||
|
|
||||||
- `GET /app.css` → `static/app.css`
|
|
||||||
- `GET /app.js` → `static/app.js`
|
|
||||||
|
|
||||||
No new Go dependencies, no JavaScript build step, no framework. The embedded
|
|
||||||
static directory and the conciseness/token-efficiency ethos of the Go rewrite
|
|
||||||
are preserved.
|
|
||||||
|
|
||||||
## 5. Behavior & Data Flow — Unchanged
|
|
||||||
|
|
||||||
Same SSE stream, same partial-update `render()` logic, same element ids, same
|
|
||||||
POST endpoints, same server-authoritative expiry timer. The redesign is markup +
|
|
||||||
CSS + asset routing only. This is precisely what keeps the existing
|
|
||||||
`web_test.go` (endpoint and state-JSON assertions, markup-agnostic) green.
|
|
||||||
|
|
||||||
## 6. Testing
|
|
||||||
|
|
||||||
- **Existing `web_test.go` stays green.** It asserts on endpoint status codes and
|
|
||||||
the state JSON, not on HTML markup, so the visual rework does not touch it.
|
|
||||||
- **New Go test:** `GET /app.css` and `GET /app.js` each return `200` with the
|
|
||||||
correct `Content-Type` (`text/css`, `text/javascript` / `application/javascript`).
|
|
||||||
Asserted against the router via `httptest`, stdlib `testing` only.
|
|
||||||
- **Manual visual checklist** across the six `data-state` values: locked,
|
|
||||||
planning, active (on-task), active (nudge), active (drift), review. There is no
|
|
||||||
JavaScript test harness in this Go project; the rendering is presentational and
|
|
||||||
verified by eye, consistent with the existing approach.
|
|
||||||
|
|
||||||
## 7. Out of Scope
|
|
||||||
|
|
||||||
- Micro-interactions / motion (timer easing, accent transitions, panel slide-in)
|
|
||||||
— explicitly excluded for M4; can be a later pass.
|
|
||||||
- Any new backend data or fields on the state payload.
|
|
||||||
- M7 reflection content (real session summary, time-on-task analytics). The M4
|
|
||||||
review screen is presentational recap of already-available data only.
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# M5 — Tasks Port Design
|
|
||||||
|
|
||||||
**Goal:** Add the `tasks.Provider` port — answering "what should I be doing?" —
|
|
||||||
with an Amazing Marvin adapter that shells out to `am --json`. Today's tasks
|
|
||||||
surface on the planning screen; clicking one seeds the intent field, which flows
|
|
||||||
into the existing AI coach. Read-only, no writeback, graceful degradation.
|
|
||||||
|
|
||||||
**Status:** Design approved 2026-05-31. Implements the deferred `tasks` port
|
|
||||||
named in `2026-05-31-go-focus-os-design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Direction
|
|
||||||
|
|
||||||
The Tasks port is the third real port, after Activity (`evidence`) and Advisor
|
|
||||||
(`ai`). It follows the pattern M1 established: a small leaf-package interface, a
|
|
||||||
single CLI adapter, and a fake for tests. It returns primitives only, so it
|
|
||||||
imports nothing from `domain` or `session`.
|
|
||||||
|
|
||||||
Its one job is to answer "what should I be doing?" with the open tasks due today
|
|
||||||
or earlier. That answer surfaces where a work intention is born — the planning
|
|
||||||
screen — as a list of clickable task titles. Clicking a title drops it into the
|
|
||||||
intent field; from there the existing coach pipeline sharpens it into a
|
|
||||||
commitment, unchanged. The task is a **seed**, not a binding link: the session
|
|
||||||
is never tied to a task ID, and nothing is written back to Marvin.
|
|
||||||
|
|
||||||
## 2. The Port
|
|
||||||
|
|
||||||
New package `internal/tasks`, a leaf package like `ai`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Task is one to-do item. Primitives only, so tasks stays a leaf package.
|
|
||||||
type Task struct {
|
|
||||||
ID string
|
|
||||||
Title string
|
|
||||||
Day string // "YYYY-MM-DD", or "" if unscheduled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider answers "what should I be doing?" — the open tasks due today or
|
|
||||||
// earlier.
|
|
||||||
type Provider interface {
|
|
||||||
Today(ctx context.Context) ([]Task, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Files under `internal/tasks/`:
|
|
||||||
|
|
||||||
- `tasks.go` — the `Provider` interface and the `Task` value type.
|
|
||||||
- `marvin.go` — the Amazing Marvin adapter and the JSON parse function.
|
|
||||||
- `tasks_test.go` / `marvin_test.go` — parse tests and adapter tests with a
|
|
||||||
fake command runner.
|
|
||||||
|
|
||||||
## 3. The Marvin Adapter
|
|
||||||
|
|
||||||
The adapter shells out exactly as `ai.claudeBackend` does: `exec.CommandContext`
|
|
||||||
with stdout captured into a buffer and failures wrapped with stderr context
|
|
||||||
(the same shape as `ai.cmdError`). It runs `am --json` (no subcommand, which
|
|
||||||
lists open tasks scheduled for today or earlier), parses the JSON array, and
|
|
||||||
maps each element to a `Task`.
|
|
||||||
|
|
||||||
`am --json` emits an array of objects of this shape (from ampy's
|
|
||||||
`_serialize_task`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
[{"id": "...", "title": "...", "parentId": "...", "day": "YYYY-MM-DD", "done": false}]
|
|
||||||
```
|
|
||||||
|
|
||||||
Only `id`, `title`, and `day` are carried into `Task`; `parentId` is ignored
|
|
||||||
(no hierarchy in M5). Any element with `done: true` is dropped defensively, even
|
|
||||||
though the default listing already returns only open tasks.
|
|
||||||
|
|
||||||
Parsing is a pure function `parse([]byte) ([]Task, error)` so it can be tested
|
|
||||||
directly against fixture strings. The shell-out wrapper holds the resolved
|
|
||||||
`cmd` and `args` and a runner func, so tests can inject a fake runner instead of
|
|
||||||
executing a real process.
|
|
||||||
|
|
||||||
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND`. The environment variable
|
|
||||||
`ANTIDRIFT_MARVIN_CMD` overrides the command; it is space-split so a value like
|
|
||||||
`uv run am` or an absolute path works. Unset or empty defaults to `am`. If `am`
|
|
||||||
cannot be found or fails at call time, `Today` returns an error and the
|
|
||||||
controller degrades to "no tasks panel" — manual planning still works. This is
|
|
||||||
the same degradation contract as the AI backend: misconfiguration never fails
|
|
||||||
startup.
|
|
||||||
|
|
||||||
## 4. Controller Wiring
|
|
||||||
|
|
||||||
The wiring mirrors the planning coach, which already fetches asynchronously and
|
|
||||||
guards against stale results.
|
|
||||||
|
|
||||||
- `SetTasks(p tasks.Provider)` injects the provider, like `SetCoach`. A nil
|
|
||||||
provider turns the feature off.
|
|
||||||
- New `Controller` fields: `tasks tasks.Provider`, `tasksStatus string`
|
|
||||||
(`idle` / `pending` / `ready` / `error`), `tasksList []tasks.Task`, and
|
|
||||||
`tasksGen int` (the generation counter).
|
|
||||||
- `EnterPlanning()` resets the tasks state and, when a provider is set, starts
|
|
||||||
an **asynchronous** `Today()` fetch in a goroutine — the same structure as
|
|
||||||
`RequestCoach`: bump `tasksGen`, set `pending`, `notify()`, then on completion
|
|
||||||
re-acquire the lock and discard the result if the generation is stale or the
|
|
||||||
runtime has left planning. Tasks are **never** fetched on the synchronous
|
|
||||||
`State()` path, which runs on every SSE broadcast.
|
|
||||||
- `State()` projects a `*TasksView{Status string, Tasks []TaskView}` **only
|
|
||||||
while planning**, alongside the existing `CoachView`. `TaskView` carries the
|
|
||||||
JSON-tagged `id`, `title`, and `day`.
|
|
||||||
|
|
||||||
No new runtime states, no new transitions, no change to the state machine.
|
|
||||||
|
|
||||||
## 5. Web / UI
|
|
||||||
|
|
||||||
No new endpoints. Tasks ride in the existing SSE state payload during planning.
|
|
||||||
|
|
||||||
The planning render in `app.js` gains a small "Today" band that lists task
|
|
||||||
titles as clickable chips. Clicking a chip sets the value of `#intent`
|
|
||||||
client-side; the user then reviews it and presses Sharpen, driving the existing
|
|
||||||
`/coach` flow. A `pending` status shows a quiet "loading tasks…" line; `error`
|
|
||||||
or an empty list renders nothing. The seed click is pure client wiring — it adds
|
|
||||||
no POST route and no new server behavior.
|
|
||||||
|
|
||||||
`main.go` gains a Marvin-adapter block parallel to the existing `ai` block: read
|
|
||||||
`ANTIDRIFT_MARVIN_CMD`, construct the adapter, call `ctrl.SetTasks(...)`, and
|
|
||||||
log one line. A construction failure logs "tasks disabled" and proceeds, never
|
|
||||||
fails startup.
|
|
||||||
|
|
||||||
## 6. Testing
|
|
||||||
|
|
||||||
- **`tasks` package:** table-driven `parse` tests — a valid array, an empty
|
|
||||||
array, malformed JSON, and `done`-filtering. An adapter test that injects a
|
|
||||||
fake runner returning canned stdout (and one returning an error) to confirm
|
|
||||||
the command path and error wrapping, without spawning a process.
|
|
||||||
- **`session` package:** with a fake `Provider`, assert the `tasksStatus`
|
|
||||||
transitions (`pending` → `ready`, and `pending` → `error` on failure) and that
|
|
||||||
`State().Tasks` reflects the fetched list while planning. A nil provider
|
|
||||||
yields no `TasksView`. Leaving planning before the fetch returns discards the
|
|
||||||
stale result (generation guard).
|
|
||||||
- **`web` package:** the existing `web_test.go` stays green (it is
|
|
||||||
markup-agnostic). Add one assertion that planning-state JSON carries the tasks
|
|
||||||
when a provider is set.
|
|
||||||
- stdlib `testing` only (no testify); `go test -race ./...` stays clean; `tasks`
|
|
||||||
stays a leaf package (imports nothing from `domain` / `session` / `evidence`).
|
|
||||||
|
|
||||||
## 7. Out of Scope
|
|
||||||
|
|
||||||
- **Writeback** — marking a task done when a session completes. Deferred per the
|
|
||||||
master design ("outcome writeback … beyond the M5 tasks port").
|
|
||||||
- Projects, categories, and task hierarchy (`parentId` is dropped).
|
|
||||||
- Binding a session to a task ID. The seed is fire-and-forget text.
|
|
||||||
- Due times, labels, estimates, and other Marvin fields.
|
|
||||||
- A manual "refresh tasks" control — the fetch on entering planning is enough
|
|
||||||
for M5.
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
# M6 — Knowledge Port Design
|
|
||||||
|
|
||||||
**Goal:** Add the `knowledge.Source` port — answering "who am I; what are my
|
|
||||||
priorities?" — with a single-config-file adapter (`~/.antidrift/knowledge.md`).
|
|
||||||
The profile text is loaded on entering planning and threaded into the AI
|
|
||||||
**coach** prompt as grounding, so "sharpen this intent" reflects who the user is
|
|
||||||
and what matters to them. A subtle planning-screen indicator shows whether the
|
|
||||||
profile loaded and from where, and lets the user point at a different file.
|
|
||||||
Read-only, graceful degradation.
|
|
||||||
|
|
||||||
**Status:** Design draft 2026-06-01. Implements the deferred `knowledge` port
|
|
||||||
named in `2026-05-31-go-focus-os-design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Direction
|
|
||||||
|
|
||||||
The Knowledge port is the fourth real port, after Activity (`evidence`),
|
|
||||||
Advisor (`ai`), and Tasks (`tasks`). It follows the same pattern: a small
|
|
||||||
leaf-package interface, a single adapter, and a fake for tests. It returns
|
|
||||||
primitives only, so it imports nothing from `domain` or `session`.
|
|
||||||
|
|
||||||
Its one job is to answer "who am I; what are my priorities?" — the standing
|
|
||||||
context that does not change session to session (role, current projects, what
|
|
||||||
counts as important, how the user likes to work). Where the Tasks port answers
|
|
||||||
*what should I be doing right now*, the Knowledge port answers *what kind of
|
|
||||||
person, with what priorities, is doing it*. That context exists to make the
|
|
||||||
advisor's judgment less generic.
|
|
||||||
|
|
||||||
**Coach-only grounding (this milestone).** The profile feeds exactly one
|
|
||||||
advisor role: the planning **coach**. Planning is the moment a vague intent is
|
|
||||||
turned into a concrete commitment, it happens at most a few times a day, and the
|
|
||||||
coach call already runs off the hot path — so grounding it is the highest-value,
|
|
||||||
lowest-cost place to start. The live drift judge and the ambient nudge are
|
|
||||||
deliberately left ungrounded in M6: they run on the hot path (debounced/cached
|
|
||||||
per window, every few minutes) and adding profile text to those prompts would
|
|
||||||
raise their token cost for marginal benefit. Extending grounding to those roles
|
|
||||||
is a clean follow-up once M6 proves the wiring (see Out of Scope).
|
|
||||||
|
|
||||||
The profile is **grounding, not instruction**: it informs the coach's proposal
|
|
||||||
but never forces a transition, exactly as the architecture's cortex layer
|
|
||||||
requires. Nothing is written back; the file is read-only.
|
|
||||||
|
|
||||||
## 2. The Port
|
|
||||||
|
|
||||||
New package `internal/knowledge`, a leaf package like `tasks` and `ai`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `path` parameter (rather than the adapter owning a single fixed path) keeps
|
|
||||||
the adapter stateless and lets the controller own the *selected* path — which
|
|
||||||
the UI can change at runtime — without a mutable field or a type assertion. An
|
|
||||||
empty `path` falls back to the adapter's configured default, and the resolved
|
|
||||||
location comes back in `Profile.Path` for the indicator to display.
|
|
||||||
|
|
||||||
Files under `internal/knowledge/`:
|
|
||||||
|
|
||||||
- `knowledge.go` — the `Source` interface and the `Profile` value type.
|
|
||||||
- `file.go` — the `FileSource` adapter (reads one file) and the small
|
|
||||||
truncation helper.
|
|
||||||
- `file_test.go` — adapter tests against temp files: present, absent, explicit
|
|
||||||
path override, oversize truncation, default-path resolution.
|
|
||||||
|
|
||||||
## 3. The File Adapter
|
|
||||||
|
|
||||||
`FileSource` reads one Markdown/plain-text file and returns its contents as the
|
|
||||||
profile. It is the knowledge analogue of the Marvin adapter, minus the
|
|
||||||
sub-process: a thin, testable wrapper around a single file read.
|
|
||||||
|
|
||||||
```go
|
|
||||||
type FileSource struct {
|
|
||||||
defaultPath string // used when Load is called with path == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileSource(defaultPath string) *FileSource
|
|
||||||
func (s *FileSource) Load(ctx context.Context, path string) (knowledge.Profile, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
Behaviour:
|
|
||||||
|
|
||||||
- **Path resolution.** `path` if non-empty, else `s.defaultPath`, else the
|
|
||||||
built-in default `~/.antidrift/knowledge.md`. `~` is expanded. The resolved
|
|
||||||
absolute path is returned in `Profile.Path` regardless of outcome, so the
|
|
||||||
indicator can always show *where it looked*.
|
|
||||||
- **Missing file** (`os.IsNotExist`) → `Profile{Path: resolved}` with empty
|
|
||||||
`Text` and **no error**. This is the expected steady state for a user who has
|
|
||||||
not written a profile; it must not look like a failure.
|
|
||||||
- **Read error** (permissions, is-a-directory, I/O) → wrapped error, same
|
|
||||||
`fmt.Errorf("knowledge: ...: %w", err)` shape the other adapters use.
|
|
||||||
- **Size cap.** The text is capped at `maxProfileBytes = 6 KiB` to bound the
|
|
||||||
coach prompt's token cost, truncated on a UTF-8 rune boundary with a trailing
|
|
||||||
`\n…(truncated)` marker. A profile that long is already an outlier; the cap is
|
|
||||||
a guard, not a feature.
|
|
||||||
- **Whitespace-only** file → treated as empty `Text` (absent), so a file of
|
|
||||||
blank lines does not produce a meaningless grounding block.
|
|
||||||
|
|
||||||
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND` / `ANTIDRIFT_MARVIN_CMD`. The
|
|
||||||
environment variable `ANTIDRIFT_KNOWLEDGE_FILE` sets the default path; unset or
|
|
||||||
empty falls back to `~/.antidrift/knowledge.md`. This is the **durable** way to
|
|
||||||
choose the file. The UI selector (§5) is a convenient **session-only** override
|
|
||||||
on top of it. A missing file or read error never fails startup — the daemon
|
|
||||||
logs one line and proceeds ungrounded.
|
|
||||||
|
|
||||||
## 4. Controller Wiring
|
|
||||||
|
|
||||||
The wiring mirrors the planning coach and the tasks fetch: an async load on
|
|
||||||
entering planning, generation-guarded against stale results, projected into
|
|
||||||
`State` only while planning. The one new seam is that the loaded text is also
|
|
||||||
**cached for the coach to consume**, since grounding flows into the coach call.
|
|
||||||
|
|
||||||
- `SetKnowledge(s knowledge.Source)` injects the source, like `SetTasks`. A nil
|
|
||||||
source turns the feature off (no indicator, ungrounded coach).
|
|
||||||
- New `Controller` fields, alongside the tasks fields:
|
|
||||||
`knowledge knowledge.Source`, `knowledgeStatus string`
|
|
||||||
(`idle`/`pending`/`ready`/`absent`/`error`), `knowledgeText string` (the
|
|
||||||
cached grounding the coach reads), `knowledgePath string` (the currently
|
|
||||||
selected path — `""` means the adapter default), `knowledgeChars int`, and
|
|
||||||
`knowledgeGen int` (the generation counter).
|
|
||||||
- `EnterPlanning()` calls `startKnowledgeFetchLocked()` right after
|
|
||||||
`startTasksFetchLocked()`: bump `knowledgeGen`, set `pending`, launch a
|
|
||||||
goroutine that calls `Load(ctx, c.knowledgePath)`, then on completion
|
|
||||||
re-acquire the lock and **discard if the generation is stale or the runtime
|
|
||||||
has left planning**. On success it sets `knowledgeStatus` to `ready` (or
|
|
||||||
`absent` when `Text == ""`), caches `knowledgeText`/`knowledgePath`/
|
|
||||||
`knowledgeChars`, and `notify()`s. Knowledge is **never** loaded on the
|
|
||||||
synchronous `State()` path.
|
|
||||||
- `State()` projects a `*KnowledgeView` **only while planning**, beside the
|
|
||||||
existing `CoachView` and `TasksView`. The view carries status, the resolved
|
|
||||||
path, and a character count — **not the profile text** (it stays server-side;
|
|
||||||
the browser never needs the body, and keeping it off the wire avoids leaking
|
|
||||||
personal context into the SSE payload and the broadcaster).
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 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 are surfaced.
|
|
||||||
type KnowledgeView struct {
|
|
||||||
Status string `json:"status"` // idle|pending|ready|absent|error
|
|
||||||
Path string `json:"path,omitempty"` // resolved source path, for display + the selector
|
|
||||||
Chars int `json:"chars,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Threading grounding into the coach.** `RequestCoach` captures
|
|
||||||
`grounding := c.knowledgeText` under the lock (next to `coach := c.coach`) and
|
|
||||||
passes it to the coach call. If the knowledge fetch is still in flight when the
|
|
||||||
user presses Sharpen, `knowledgeText` is simply empty for that one call and the
|
|
||||||
coach runs ungrounded — the same graceful-degradation contract as a missing
|
|
||||||
file. No awaiting, no blocking.
|
|
||||||
|
|
||||||
This requires a **signature change** to the `ai.Coach` interface — the one
|
|
||||||
non-additive change in M6:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Coach interface {
|
|
||||||
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Service.Coach` passes `grounding` to `buildPrompt(intent, grounding)`, which
|
|
||||||
prepends an `## About the user` section **only when grounding is non-empty**, so
|
|
||||||
an ungrounded call produces a byte-for-byte unchanged prompt. The ripple is
|
|
||||||
small and mechanical: the impl in `ai/coach.go`, the call site in
|
|
||||||
`session.go`, and the coach tests/fakes that implement the interface. `DriftJudge`
|
|
||||||
and `Nudger` are untouched.
|
|
||||||
|
|
||||||
**Explicit file selection.** `SetKnowledgePath(path string)` stores the selected
|
|
||||||
path and, while planning, kicks off a fresh `startKnowledgeFetchLocked()` so the
|
|
||||||
indicator and cached grounding update immediately. It is session-only — not
|
|
||||||
persisted to the snapshot — so a restart returns to the
|
|
||||||
`ANTIDRIFT_KNOWLEDGE_FILE`/default. (Persisting the selection is a snapshot-schema
|
|
||||||
change deferred to a later milestone; the env var is the durable knob for now.)
|
|
||||||
|
|
||||||
No new runtime states, no new transitions, no change to the state machine, no
|
|
||||||
change to persisted snapshot shape.
|
|
||||||
|
|
||||||
## 5. Web / UI
|
|
||||||
|
|
||||||
The profile load rides in the existing SSE state payload during planning, beside
|
|
||||||
coach and tasks. M6 adds **one** small POST route — the file selector — which is
|
|
||||||
the single deliberate server write in this milestone.
|
|
||||||
|
|
||||||
- **Indicator.** The planning render gains a quiet line under the intent band
|
|
||||||
(near `coachStatus`): `ready` → `grounded by <basename(path)>`; `absent` →
|
|
||||||
`no profile (~/.antidrift/knowledge.md)`; `error` → `profile unreadable`;
|
|
||||||
`pending` → `loading profile…`; nil source / `idle` → nothing. It is
|
|
||||||
ambient and non-blocking, matching the tasks "loading…" treatment — never a
|
|
||||||
button to fight.
|
|
||||||
- **Selector.** A small "change" affordance next to the indicator reveals an
|
|
||||||
input pre-filled with the resolved path; submitting POSTs
|
|
||||||
`/knowledge/path` with a `path` field, which calls `ctrl.SetKnowledgePath`
|
|
||||||
and re-loads. This is the *only* new endpoint. It mutates session-only config,
|
|
||||||
not commitment state, so it sits outside the state machine. Submitting an
|
|
||||||
empty path resets to the default. The control is intentionally minimal; if it
|
|
||||||
proves more than needed, it can ship behind the env var alone (the indicator
|
|
||||||
is the core; the selector is the "maybe").
|
|
||||||
- `main.go` gains a knowledge-adapter block parallel to the `tasks` block: read
|
|
||||||
`ANTIDRIFT_KNOWLEDGE_FILE`, construct `knowledge.NewFileSource(...)`, call
|
|
||||||
`ctrl.SetKnowledge(...)`, and log one line. Construction never fails; a bad
|
|
||||||
path only surfaces (as `absent`/`error`) at load time.
|
|
||||||
|
|
||||||
## 6. Testing
|
|
||||||
|
|
||||||
- **`knowledge` package:** adapter tests against temp files — a present file
|
|
||||||
(text + resolved path returned), an absent file (empty `Text`, no error,
|
|
||||||
path still reported), an explicit `path` override beating the default, an
|
|
||||||
oversize file truncated on a rune boundary with the marker, a whitespace-only
|
|
||||||
file treated as absent, and a permission/read error wrapped. `~` expansion
|
|
||||||
covered with a synthesized home. stdlib `testing` only; no process spawned.
|
|
||||||
- **`ai` package:** `buildPrompt` includes the `## About the user` block when
|
|
||||||
grounding is non-empty and is byte-identical to the pre-M6 prompt when
|
|
||||||
grounding is empty (a table test pins both). Existing coach/drift/nudge tests
|
|
||||||
updated for the new `Coach` signature (grounding `""`), staying green.
|
|
||||||
- **`session` package:** with a fake `Source`, assert `knowledgeStatus`
|
|
||||||
transitions (`pending` → `ready`, `pending` → `absent` on empty text,
|
|
||||||
`pending` → `error` on failure) and that `State().Knowledge` reflects them
|
|
||||||
while planning and is absent otherwise / with a nil source. Assert
|
|
||||||
`RequestCoach` passes the cached `knowledgeText` to a recording fake coach,
|
|
||||||
and passes `""` when the fetch has not completed. Assert the generation guard
|
|
||||||
discards a load that returns after leaving planning, and that
|
|
||||||
`SetKnowledgePath` re-fetches. A nil source yields no `KnowledgeView` and an
|
|
||||||
ungrounded coach.
|
|
||||||
- **`web` package:** existing tests stay green (markup-agnostic). Add one
|
|
||||||
assertion that planning-state JSON carries the knowledge object (status +
|
|
||||||
path, no text) when a source is set, and one that `POST /knowledge/path`
|
|
||||||
updates the selected path and triggers a re-load.
|
|
||||||
- `go vet ./... && go test -race ./...` stays clean; `knowledge` stays a leaf
|
|
||||||
package (imports only `context` + stdlib `os`/`io`/`path`/`strings`/`unicode`
|
|
||||||
— nothing from `domain`/`session`/`evidence`/`ai`/`web`).
|
|
||||||
|
|
||||||
## 7. Out of Scope
|
|
||||||
|
|
||||||
- **Grounding the drift judge and nudge.** Coach-only in M6. Extending profile
|
|
||||||
grounding to the hot-path roles is a deliberate follow-up, gated on whether
|
|
||||||
the token cost is worth it.
|
|
||||||
- **PKM-directory and CLI adapters.** M6 ships exactly one file adapter. The
|
|
||||||
`path`-parameter port shape leaves room for a directory or `am`-style CLI
|
|
||||||
adapter later without an interface change, but we do not build or abstract for
|
|
||||||
them now (YAGNI).
|
|
||||||
- **Persisting the selected path** across restarts (a snapshot-schema change).
|
|
||||||
The env var is the durable knob; the UI override is session-only.
|
|
||||||
- **Live file watching / auto-reload.** The profile is re-read on each entry to
|
|
||||||
planning (and on explicit reselection); no inotify, no polling.
|
|
||||||
- **Editing the profile from the UI**, structured profile fields (parsing the
|
|
||||||
Markdown into sections), or per-project knowledge. The file is an opaque
|
|
||||||
grounding blob.
|
|
||||||
- **Shipping the profile text to the browser.** Only presence, path, and size
|
|
||||||
cross the wire.
|
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# Windows 11 Support — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-02
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Scope:** Full feature parity on Windows 11 — both OS ports (active-window
|
||||||
|
sensing and window-minimize enforcement) — implemented as `//go:build windows`
|
||||||
|
adapters behind the existing interfaces. No consumer code changes.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
AntiDrift's value is making drift visible by watching the active window and, on
|
||||||
|
confirmed off-task drift, minimizing it. Both capabilities are X11-only, gated
|
||||||
|
behind `//go:build linux`. On any non-Linux target the build links the
|
||||||
|
`//go:build !linux` no-op fallbacks, so the daemon starts and serves its UI but
|
||||||
|
the entire focus loop is dead: no evidence, no drift detection, no nudges, no
|
||||||
|
enforcement.
|
||||||
|
|
||||||
|
Today's Windows behavior, verified:
|
||||||
|
|
||||||
|
- `GOOS=windows GOARCH=amd64 go build ./cmd/antidriftd` compiles cleanly (no
|
||||||
|
cgo).
|
||||||
|
- The daemon would launch and open a browser (`cmd/antidriftd/main.go` already
|
||||||
|
handles the `windows` case via `rundll32`).
|
||||||
|
- `evidence.NewSource()` returns a no-op reporting "no active-window sensor on
|
||||||
|
this platform" (`internal/evidence/source_other.go`).
|
||||||
|
- `enforce.NewGuard()` returns a no-op whose `MinimizeActive` does nothing
|
||||||
|
(`internal/enforce/guard_other.go`).
|
||||||
|
|
||||||
|
This design fills both ports on Windows so AntiDrift functions end-to-end.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- A real `evidence.Source` on Windows: emit `WindowSnapshot{Title, Class,
|
||||||
|
Health}` on foreground-window and title changes.
|
||||||
|
- A real `enforce.Guard` on Windows: minimize the foreground window on demand.
|
||||||
|
- Pure Go, no cgo. Cross-compilation from Linux stays clean.
|
||||||
|
- No changes to `session`, `web`, `domain`, or `cmd` — the work lives entirely
|
||||||
|
behind the two existing ports.
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- Event-driven sensing via `SetWinEventHook` (see Approach B, rejected below).
|
||||||
|
The `Source` interface hides the polling-vs-event choice; B can replace A
|
||||||
|
later with zero consumer impact if latency ever matters.
|
||||||
|
- Windows ARM64.
|
||||||
|
- Installer, system tray, packaging, autostart.
|
||||||
|
- Any session-policy or web/UI changes.
|
||||||
|
|
||||||
|
## Constraints that shaped this design
|
||||||
|
|
||||||
|
- **Verification is compile-only for now.** Development is on Manjaro Linux with
|
||||||
|
no Windows 11 machine available. The design therefore minimizes untested
|
||||||
|
syscall surface: no callbacks, no Win32 message loop, no OS-thread affinity.
|
||||||
|
Live runtime verification is deferred (see Testing).
|
||||||
|
|
||||||
|
## Port contracts being satisfied
|
||||||
|
|
||||||
|
From `internal/evidence/evidence.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type WindowSnapshot struct {
|
||||||
|
Title string // full window title
|
||||||
|
Class string // app identity, matched case-folded against allowed classes
|
||||||
|
Health EvidenceHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source interface {
|
||||||
|
// Watch runs until ctx is cancelled, invoking onChange on every
|
||||||
|
// active-window change, and once immediately with the current window.
|
||||||
|
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From `internal/enforce/enforce.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Guard interface {
|
||||||
|
// MinimizeActive minimizes the currently-focused window. Idempotent and
|
||||||
|
// best-effort: returns an error for diagnostics; callers never block on it.
|
||||||
|
MinimizeActive(ctx context.Context) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Class` field is the authoritative on-task signal. `MatchesAllowed`
|
||||||
|
(`internal/evidence/context.go`) compares it **case-folded, exact match**
|
||||||
|
against the session's allowed window classes (or matches a title substring).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All new code is behind `//go:build windows`. No consumer changes — `session`,
|
||||||
|
`web`, and `cmd/antidriftd` already call `evidence.NewSource()` /
|
||||||
|
`enforce.NewGuard()` and degrade on the health flags.
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `internal/winapi/winapi.go` (`//go:build windows`) — a small shared binding
|
||||||
|
layer over the Win32 calls, so the two adapters don't each redeclare the same
|
||||||
|
procs.
|
||||||
|
- `internal/evidence/windows.go` (`//go:build windows`) — the polling `Source`.
|
||||||
|
- `internal/enforce/windows.go` (`//go:build windows`) — the `ShowWindow`
|
||||||
|
`Guard`.
|
||||||
|
|
||||||
|
### Build-tag edit (correctness-critical)
|
||||||
|
|
||||||
|
The fallbacks are currently tagged `//go:build !linux`, which is what compiles
|
||||||
|
on Windows today. Once `windows.go` files exist in those packages, the fallbacks
|
||||||
|
must exclude Windows too, or the build gets duplicate `NewSource`/`NewGuard`
|
||||||
|
symbols:
|
||||||
|
|
||||||
|
- `internal/evidence/source_other.go`: `//go:build !linux` →
|
||||||
|
`//go:build !linux && !windows`
|
||||||
|
- `internal/enforce/guard_other.go`: `//go:build !linux` →
|
||||||
|
`//go:build !linux && !windows`
|
||||||
|
|
||||||
|
This yields three mutually exclusive worlds per port: `linux` (X11), `windows`
|
||||||
|
(new), everything-else (no-op). macOS and any other GOOS remain no-ops, exactly
|
||||||
|
as today.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
`golang.org/x/sys` is already in `go.mod` (indirect, v0.41.0). `go mod tidy`
|
||||||
|
promotes it to a direct require. No new module, no cgo.
|
||||||
|
|
||||||
|
## Component design
|
||||||
|
|
||||||
|
### `internal/winapi` — shared Win32 binding
|
||||||
|
|
||||||
|
Most of the needed calls are already typed wrappers in `x/sys/windows` (verified
|
||||||
|
under `GOOS=windows go doc`), so the hand-written binding is deliberately tiny.
|
||||||
|
|
||||||
|
Provided by `golang.org/x/sys/windows`, used directly — no LazyDLL needed:
|
||||||
|
|
||||||
|
- `GetForegroundWindow() HWND`
|
||||||
|
- `GetWindowThreadProcessId(hwnd HWND, *uint32) (tid uint32, err error)` → owning PID
|
||||||
|
- `OpenProcess`, `QueryFullProcessImageName`, `CloseHandle` → process image path
|
||||||
|
- the `windows.HWND` type and the `windows.SW_MINIMIZE` (= 6) constant
|
||||||
|
|
||||||
|
Not wrapped by `x/sys/windows`; loaded once via
|
||||||
|
`windows.NewLazySystemDLL("user32.dll")` and called through `proc.Call`:
|
||||||
|
|
||||||
|
- `GetWindowTextW(hwnd, *uint16, max int32) int32` → title
|
||||||
|
- `ShowWindow(hwnd, SW_MINIMIZE) bool`
|
||||||
|
|
||||||
|
Public surface (two helpers the adapters consume):
|
||||||
|
|
||||||
|
- `ForegroundWindow() (hwnd uintptr, title, class string, ok bool)` — resolves
|
||||||
|
the foreground window's title and process-exe-base `class` in one call. `ok`
|
||||||
|
is false when there is no foreground window (null hwnd) — e.g. secure desktop,
|
||||||
|
UAC prompt, lock screen.
|
||||||
|
- `MinimizeForeground() error` — minimizes the current foreground window;
|
||||||
|
returns nil when nothing is focused.
|
||||||
|
|
||||||
|
`class` is derived from the owning process image path
|
||||||
|
(`QueryFullProcessImageName`) as the **base name minus the `.exe` extension,
|
||||||
|
lowercased**: `C:\Program Files\Microsoft VS Code\Code.exe` → `code`. This is
|
||||||
|
the closest analog to X11 `WM_CLASS` conventions and keeps allowed-class lists
|
||||||
|
readable. Path parsing is a pure function (see Testing).
|
||||||
|
|
||||||
|
### `evidence.Source` (Windows) — polling
|
||||||
|
|
||||||
|
`Watch(ctx, onChange)`:
|
||||||
|
|
||||||
|
1. Emit one snapshot immediately (honors the "once immediately" contract).
|
||||||
|
2. `time.NewTicker(750 * time.Millisecond)`.
|
||||||
|
3. On each tick, call `winapi.ForegroundWindow()` and track the last
|
||||||
|
`(hwnd, title)`. Invoke `onChange` **only when `hwnd` or `title` changed** —
|
||||||
|
a steady window stays silent, and a same-window title change (e.g. a browser
|
||||||
|
tab switch) still fires. This reproduces the fidelity the X11 source gets
|
||||||
|
from `_NET_ACTIVE_WINDOW` plus name property notifies.
|
||||||
|
4. Map a successful read to
|
||||||
|
`WindowSnapshot{Title: title, Class: class, Health: {Available: true}}`.
|
||||||
|
5. Map `ok == false` to
|
||||||
|
`WindowSnapshot{Health: {Available: false, Reason: "no foreground window"}}`.
|
||||||
|
Recovers automatically on the next tick.
|
||||||
|
6. On `ctx.Done()`, stop the ticker and return. Nothing else to tear down.
|
||||||
|
|
||||||
|
Rationale for polling over `SetWinEventHook`: under compile-only verification,
|
||||||
|
the responsible choice is the design with no callback trampoline
|
||||||
|
(`syscall.NewCallback`), no `GetMessage`/`DispatchMessage` loop, and no
|
||||||
|
`runtime.LockOSThread` — three failure modes that are hard to validate without a
|
||||||
|
Windows machine. ~1s switch latency is irrelevant for a focus tracker, and
|
||||||
|
polling also catches in-window title changes for free.
|
||||||
|
|
||||||
|
### `enforce.Guard` (Windows)
|
||||||
|
|
||||||
|
`MinimizeActive(ctx)`:
|
||||||
|
|
||||||
|
1. Read the foreground window. If null, return nil (nothing focused — same as
|
||||||
|
the X11 `active == 0` case).
|
||||||
|
2. Otherwise `ShowWindow(hwnd, SW_MINIMIZE)`; on failure return a wrapped error.
|
||||||
|
|
||||||
|
Per-call and stateless, mirroring the short-lived X11 connection model. Errors
|
||||||
|
are for the caller to log; the caller never blocks on enforcement.
|
||||||
|
|
||||||
|
## Error handling and degradation
|
||||||
|
|
||||||
|
The existing contracts are unchanged; this design simply honors them on Windows.
|
||||||
|
|
||||||
|
- Sensor can't read a foreground window → `Available: false` snapshot with a
|
||||||
|
reason. The daemon already treats this as "no evidence this tick" and surfaces
|
||||||
|
the reason in the live view. Auto-recovers on the next tick.
|
||||||
|
- `ShowWindow` fails → error returned and logged upstream; enforcement is
|
||||||
|
best-effort, treated as "did nothing this time." No panic, no block.
|
||||||
|
- No new failure types reach `session`/`web`: they still see only a
|
||||||
|
`WindowSnapshot` and a possible `MinimizeActive` error, exactly as with X11.
|
||||||
|
|
||||||
|
## Known cross-platform nuance (not a bug)
|
||||||
|
|
||||||
|
The same application can produce a different `Class` on Linux vs Windows (X11
|
||||||
|
`WM_CLASS` vs Windows exe base name; e.g. Chrome may be `google-chrome` on Linux
|
||||||
|
and `chrome` on Windows). Allowed-class lists are per-session and case-folded,
|
||||||
|
so this only affects portability of class names *across machines*, never
|
||||||
|
matching within a single session. Documented, not engineered around.
|
||||||
|
|
||||||
|
## Testing and verification
|
||||||
|
|
||||||
|
Bounded honestly by the compile-only constraint.
|
||||||
|
|
||||||
|
1. **Cross-compilation gate (primary automated guarantee).**
|
||||||
|
`GOOS=windows GOARCH=amd64 go build ./...` must pass. Also build
|
||||||
|
`GOOS=darwin` to prove the `!linux && !windows` tag edit still selects the
|
||||||
|
no-op fallback (i.e. the everything-else world is intact).
|
||||||
|
2. **Pure-logic unit tests, no build tag (run on Linux).** The syscall-free
|
||||||
|
logic is extracted into pure functions and tested directly:
|
||||||
|
- exe-path → class normalization: `Code.exe` → `code`, strip `.exe`,
|
||||||
|
lowercase, handle no-extension names and UNC/odd separators.
|
||||||
|
- change-detection predicate: emit on `(hwnd, title)` change, stay silent
|
||||||
|
otherwise.
|
||||||
|
3. **No fabricated syscall mocks.** We will not write a fake that pretends to
|
||||||
|
exercise `user32` — that produces false confidence. Live verification is
|
||||||
|
deferred until a Windows 11 machine is available.
|
||||||
|
4. **`x11_integration_test.go` files stay `//go:build linux`**, untouched.
|
||||||
|
|
||||||
|
### Deferred manual verification (when a Windows 11 machine is available)
|
||||||
|
|
||||||
|
- Run `antidriftd`; confirm the browser opens and the live view shows the
|
||||||
|
current window title/class.
|
||||||
|
- Switch windows and change a browser tab; confirm snapshots update and the
|
||||||
|
health reads available.
|
||||||
|
- Start a commitment with "Enforce focus" armed; focus an off-task window;
|
||||||
|
confirm the drift judge fires and the window minimizes.
|
||||||
|
|
||||||
|
## Out of scope (restated)
|
||||||
|
|
||||||
|
`SetWinEventHook`, Windows ARM64, tray/installer/packaging/autostart, and any
|
||||||
|
session/web/domain changes. This is pure adapter work behind two existing ports.
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# Controller Refactor — Harness + Modes — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Scope:** Rewrite the focus-only session controller into a generic
|
||||||
|
**harness** that hosts swappable **modes**. Focus becomes the first mode
|
||||||
|
(behavior-preserving extraction); **off-screen mode** becomes the second
|
||||||
|
(new, the proof the abstraction generalizes). The `antidrift`→`keel` code
|
||||||
|
rename and `~/.antidrift/`→`~/.keel/` runtime move land in the same pass,
|
||||||
|
clean-slate (no state migration).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`session.Controller` (≈1,400 LOC across `session.go`, `roles.go`, `drift.go`,
|
||||||
|
`stats.go`, `views.go`) is a god-object. It conflates three things:
|
||||||
|
|
||||||
|
1. **Generic runtime orchestration** — holding the ports, the async
|
||||||
|
brain-call primitive (`runFetchAsync`), persistence, change-notify, the
|
||||||
|
evidence ingestion callback, the clock.
|
||||||
|
2. **The focus state machine** — `locked→planning→active→transition→review`,
|
||||||
|
commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence
|
||||||
|
stats, reflection, session sealing.
|
||||||
|
3. **The focus domain payload** — `domain.Commitment`
|
||||||
|
(`next_action / success_condition / timebox`), sitting in a package named
|
||||||
|
`domain` as if it were generic.
|
||||||
|
|
||||||
|
Focus mode is not *a* mode — it **is** the controller. There is no seam to add
|
||||||
|
a second mode. Keel's thesis (the harness is the product; focus is one mode
|
||||||
|
among many) cannot be expressed until (1) is separated from (2)+(3).
|
||||||
|
|
||||||
|
The directory and git repo are already `keel`; only the code identity still
|
||||||
|
says `antidrift`. AntiDrift was never in production and its `~/.antidrift/`
|
||||||
|
ledger is disposable, so the rename and runtime move can be done outright with
|
||||||
|
no migration ceremony.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- A generic `internal/harness` host that owns shared services and exactly one
|
||||||
|
active mode, and routes I/O to it.
|
||||||
|
- A small `Mode` contract that holds both a long-lived stateful mode (focus)
|
||||||
|
and a one-shot request/response mode (off-screen) without crippling either.
|
||||||
|
- Focus re-homed as `mode/focus` with **identical behavior** — existing focus
|
||||||
|
tests are the regression oracle.
|
||||||
|
- Off-screen mode built end-to-end: collect → assemble → brain → surface →
|
||||||
|
gated Marvin write.
|
||||||
|
- The `antidrift`→`keel` rename complete in code and runtime, clean-slate.
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- **AW-bucket memory** (the §7 "remember across runs" layer). Off-screen v1 is
|
||||||
|
stateless. AW memory is the immediate fast-follow, its own spec.
|
||||||
|
- **Concurrent / ambient modes.** Exactly one active mode at a time
|
||||||
|
(idle ↔ focus *or* off-screen). Matches today's single-commitment reality.
|
||||||
|
- **Any third mode.**
|
||||||
|
- **State migration** from `~/.antidrift/`. Clean slate by decision.
|
||||||
|
- Renaming the off-screen mode's working code name (`offscreen`) — placeholder,
|
||||||
|
rename freely later.
|
||||||
|
|
||||||
|
## Decisions that shaped this design
|
||||||
|
|
||||||
|
- **One thin `Mode` interface + capability interfaces**, not a stateless
|
||||||
|
collect/assemble/effector pipeline framework (Procrustean for focus's
|
||||||
|
stateful machine, and a YAGNI framework for two modes), and not two formal
|
||||||
|
mode *kinds* (fragments the concept before the seams are felt). Capability
|
||||||
|
interfaces (`EvidenceConsumer`, `Expirer`) give the heterogeneity honesty
|
||||||
|
without the taxonomy. If a third one-shot mode appears, off-screen's flow can
|
||||||
|
be promoted into a real pipeline helper then — driven by evidence.
|
||||||
|
- **Generalize the web routes** (`/mode/{kind}/start`, `/mode/command/{name}`)
|
||||||
|
rather than keep focus's bespoke endpoints. The frontend is rewritten under
|
||||||
|
the state envelope regardless.
|
||||||
|
- **`Locked` collapses into harness idle.** "No active mode" is the resting
|
||||||
|
state; focus is `Planning→Active→Review→done`. One fewer state.
|
||||||
|
- **Off-screen reuses the existing single-file `knowledge.Source`** for the
|
||||||
|
`~/owc/goals-2026.md` frame, plus a small in-package glob read
|
||||||
|
(`filepath.Glob` + read) for the curated life-bug set. `knowledge.Source.Load`
|
||||||
|
is single-path by contract; no new adapter package — smallest real slice.
|
||||||
|
- **The confirm click is the effector gate.** No auto-write in v1.
|
||||||
|
|
||||||
|
## Package layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/keeld/main.go # wires harness + modes + surfaces + settings
|
||||||
|
|
||||||
|
internal/harness/
|
||||||
|
harness.go # Harness: services, the one active mode, change-notify, store root
|
||||||
|
services.go # Services handle passed to modes
|
||||||
|
async.go # generation-guarded async primitive (was runFetchAsync)
|
||||||
|
|
||||||
|
internal/mode/
|
||||||
|
mode.go # Mode interface, capability interfaces, Envelope
|
||||||
|
|
||||||
|
internal/mode/focus/ # today's session/* + domain/* + statemachine/* re-homed
|
||||||
|
focus.go statemachine.go drift.go stats.go views.go roles.go ...
|
||||||
|
|
||||||
|
internal/mode/offscreen/
|
||||||
|
offscreen.go # NEW: off-screen mode
|
||||||
|
|
||||||
|
internal/ai/ tasks/ knowledge/ evidence/ enforce/ # shared ports — only module path changes
|
||||||
|
internal/store/ # persistence primitives + per-mode namespacing
|
||||||
|
internal/settings/ statusfile/ web/ # web becomes mode-aware
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/domain` dissolves into `mode/focus`: `Commitment`, `RuntimeState`,
|
||||||
|
`EnforcementLevel`, `AllowedContext`, `PolicySnapshot` are all focus concepts.
|
||||||
|
`internal/statemachine` likewise moves under `mode/focus` (its states are the
|
||||||
|
focus lifecycle). Off-screen touches none of them.
|
||||||
|
|
||||||
|
## The Mode contract
|
||||||
|
|
||||||
|
```go
|
||||||
|
package mode
|
||||||
|
|
||||||
|
// Mode is one collect→brain→act configuration. The harness runs at most one.
|
||||||
|
type Mode interface {
|
||||||
|
Kind() string // "focus", "offscreen"
|
||||||
|
Command(ctx context.Context, name string, payload json.RawMessage) error
|
||||||
|
View() any // JSON-marshalable projection
|
||||||
|
Active() bool // false ⇒ harness returns to idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional capabilities — the harness asserts for these.
|
||||||
|
type EvidenceConsumer interface { OnWindow(evidence.WindowSnapshot) } // focus only
|
||||||
|
type Expirer interface { Deadline() time.Time; Expire(context.Context) error } // focus only
|
||||||
|
```
|
||||||
|
|
||||||
|
`Command` returns an error for illegal/invalid commands; the web layer maps it
|
||||||
|
to HTTP (today's `IllegalTransitionError`→409 mapping moves with focus). `View`
|
||||||
|
returns the mode's own shape, wrapped by the harness in the envelope below.
|
||||||
|
|
||||||
|
## The Harness + Services
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Harness struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
active mode.Mode // nil = idle
|
||||||
|
factories map[string]func(Services) mode.Mode // "focus", "offscreen"
|
||||||
|
services Services
|
||||||
|
onChange []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) Start(kind string) error // idle → mode (single-active invariant)
|
||||||
|
func (h *Harness) Command(ctx, name, payload) error // routes to active; cleans up active→done
|
||||||
|
func (h *Harness) State() mode.Envelope
|
||||||
|
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) // forwards iff active is EvidenceConsumer
|
||||||
|
func (h *Harness) Deadline() time.Time // delegates iff active is Expirer
|
||||||
|
func (h *Harness) Expire(ctx) error // delegates iff active is Expirer
|
||||||
|
```
|
||||||
|
|
||||||
|
After any `Command`/`Start`, if `active.Active()` is false the harness clears
|
||||||
|
`active` to nil (idle). `Start` on a non-idle harness is an error unless the
|
||||||
|
requested kind is already active.
|
||||||
|
|
||||||
|
`Services` is what a mode receives — the ports plus relocated infrastructure:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Services struct {
|
||||||
|
AI *ai.Service // coach/judge/nudge/reviewer + new proposer role
|
||||||
|
Tasks tasks.Provider // read (Today) + new write (Create)
|
||||||
|
Knowledge knowledge.Source
|
||||||
|
Enforce enforce.Guard
|
||||||
|
Clock func() time.Time
|
||||||
|
Store *store.Scope // mode-namespaced (~/.keel/modes/<kind>/)
|
||||||
|
Notify func() // fires harness change-listeners (SSE + status bar)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The async primitive
|
||||||
|
|
||||||
|
`runFetchAsync` (today `session/roles.go:99`) moves to `harness/async.go` as a
|
||||||
|
helper parameterized by **the mode's own mutex** plus `Services.Notify`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Async struct { mu *sync.Mutex; notify func() }
|
||||||
|
func (a Async) Run(timeout time.Duration, fetch func(context.Context), stale func() bool, apply func())
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves today's exact lock discipline: `fetch` runs with no lock held;
|
||||||
|
`stale`/`apply` run under the mode's mutex; `notify` fires once, after unlock.
|
||||||
|
Each mode constructs one `Async{mu: &m.mu, notify: svc.Notify}`. Generation
|
||||||
|
counters and status enums stay inside each mode, exactly as today.
|
||||||
|
|
||||||
|
## Focus mode (behavior-preserving extraction)
|
||||||
|
|
||||||
|
`session.Controller` minus the host plumbing becomes `focus.Mode`. The state
|
||||||
|
machine, drift/nudge/enforcement, evidence stats, reflection, and the planning
|
||||||
|
coach/tasks/knowledge fetches move verbatim. Focus implements `Mode`,
|
||||||
|
`EvidenceConsumer` (today's `RecordWindow`), and `Expirer` (today's
|
||||||
|
deadline/`Expire`). Its `View()` returns today's `session.State` shape,
|
||||||
|
unchanged, so the existing focus screens render identically.
|
||||||
|
|
||||||
|
Command mapping (today's routes → focus `Command` names): `planning`, `coach`,
|
||||||
|
`commitment`, `complete`, `end`, `refocus`, `ontask`.
|
||||||
|
|
||||||
|
`Locked` is removed: harness idle replaces it. `Planning→Active→Review→done`,
|
||||||
|
then the harness returns to idle. `admin_override` stays inside focus (an
|
||||||
|
enforcement-escape concern). **Existing focus tests move with the package and
|
||||||
|
must pass unchanged** — they are the proof the extraction preserved behavior.
|
||||||
|
|
||||||
|
## Off-screen mode (the proof)
|
||||||
|
|
||||||
|
The away-from-the-desk complement to focus. A one-shot request/response
|
||||||
|
lifecycle — this is what proves the abstraction holds for a mode that is *not*
|
||||||
|
a long-lived session.
|
||||||
|
|
||||||
|
```
|
||||||
|
start → assembling → proposed → (confirm | dismiss) → done
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Command "start"`** — collect + assemble + brain via the async primitive;
|
||||||
|
view status `pending`.
|
||||||
|
- **Collect (real frame elements):**
|
||||||
|
- `tasks.Provider.Today()` — life / non-work items.
|
||||||
|
- `~/owc/goals-2026.md` (Body/Spirit, People, Via Negativa) via the existing
|
||||||
|
single-file `knowledge.Source`.
|
||||||
|
- `~/owc` life-domain bugs via a small in-package glob read:
|
||||||
|
`physical-reset`, `workout-effort`, `nutrition-effort`,
|
||||||
|
`missing-social-life`, `make-environment-beautiful`, `marriage-strategy`.
|
||||||
|
- **Brain:** a new thin `ai.Proposer` role —
|
||||||
|
`Propose(ctx, brief) → Proposal{NextAction, Rationale}` — reusing
|
||||||
|
`ai.Backend`. A sibling to `Coach`, not a bend of it.
|
||||||
|
- **Surface:** phone-friendly proposal card (off-screen ⇒ you're not at the
|
||||||
|
desk; the phone surface earns its keep here).
|
||||||
|
- **`Command "confirm"` (gated effector):** a **new**
|
||||||
|
`tasks.Provider.Create(ctx, Task)` files the proposed task in Marvin,
|
||||||
|
preserving the `fieldUpdates` CRDT on write. The confirm click *is* the gate.
|
||||||
|
- **`Command "dismiss"`:** done, nothing written.
|
||||||
|
- **Memory:** none in v1 (AW buckets deferred).
|
||||||
|
|
||||||
|
Off-screen implements only `Mode` (no evidence stream, no timebox), so it
|
||||||
|
exercises the capability-assertion path: the harness forwards neither window
|
||||||
|
events nor an expiry timer to it.
|
||||||
|
|
||||||
|
## Surfacing
|
||||||
|
|
||||||
|
```go
|
||||||
|
package mode
|
||||||
|
type Envelope struct {
|
||||||
|
ActiveMode string `json:"active_mode"` // "" = idle
|
||||||
|
Mode any `json:"mode,omitempty"` // active mode's View()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Web routes generalize:
|
||||||
|
|
||||||
|
- `POST /mode/{kind}/start` → `harness.Start`
|
||||||
|
- `POST /mode/command/{name}` → `harness.Command` (request body = payload)
|
||||||
|
- `GET /events` → SSE of the envelope (broadcaster mechanism unchanged)
|
||||||
|
- The server-owned expiry timer arms from `harness.Deadline()` and fires
|
||||||
|
`harness.Expire()` via the `Expirer` assertion (today's `web.armExpiry`
|
||||||
|
logic, retargeted).
|
||||||
|
|
||||||
|
`app.js` switches on `active_mode`: `""` → launcher (pick focus / off-screen);
|
||||||
|
`"focus"` → today's screens, fed by `.mode`; `"offscreen"` → the proposal card.
|
||||||
|
The status-file writer renders one line per active mode (focus's drift line,
|
||||||
|
off-screen's proposal summary, or `idle`). **This is the largest single chunk
|
||||||
|
of new work**: the focus UI is reshaped under the envelope and a small
|
||||||
|
off-screen view is added.
|
||||||
|
|
||||||
|
## Persistence (clean slate)
|
||||||
|
|
||||||
|
- Root `~/.keel/`; settings `~/.keel/settings.json`; status `~/.keel_status`.
|
||||||
|
- Per-mode namespace via `store.Scope`:
|
||||||
|
`~/.keel/modes/focus/{state.json, audit.jsonl, sessions/}`.
|
||||||
|
- Off-screen v1 persists nothing (ephemeral; restart = idle).
|
||||||
|
- **No migration** from `~/.antidrift/`.
|
||||||
|
|
||||||
|
## The rename (lands in this pass)
|
||||||
|
|
||||||
|
In-repo:
|
||||||
|
|
||||||
|
- `go.mod` module `antidrift`→`keel`; all import paths.
|
||||||
|
- `cmd/antidriftd`→`cmd/keeld`; binary `keeld`; log strings.
|
||||||
|
- `store`/`settings`/`statusfile` default paths → `~/.keel*`.
|
||||||
|
- `settings.SeedFromEnv` `ANTIDRIFT_*`→`KEEL_*`.
|
||||||
|
- `CLAUDE.md` naming-status section (rename now *done*).
|
||||||
|
- `keel-architecture.md`: §1/§8 (rename resolved), §5/§10 (remove the phantom
|
||||||
|
"house mode"; describe off-screen mode with the real collectors above).
|
||||||
|
|
||||||
|
External — Felix updates by hand (out of repo, listed in the plan):
|
||||||
|
|
||||||
|
- WM status-bar config reading `~/.antidrift_status` → `~/.keel_status`.
|
||||||
|
- Any systemd unit / shell alias / launcher invoking `antidriftd` → `keeld`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Focus:** existing tests relocate with the package (import paths only) and
|
||||||
|
must pass unchanged — the regression oracle for the extraction.
|
||||||
|
- **Harness:** new unit tests with a fake mode — single-active invariant,
|
||||||
|
command routing, capability assertion (window events only to
|
||||||
|
`EvidenceConsumer`s; expiry only to `Expirer`s), idle↔active transitions,
|
||||||
|
`Active()==false` cleanup to idle.
|
||||||
|
- **Off-screen:** new unit tests with fake `ai`/`tasks` — collect→propose then
|
||||||
|
`confirm` files exactly one task (CRDT-preserving); `dismiss` writes nothing;
|
||||||
|
the gate holds.
|
||||||
|
- **Web:** envelope routing test (idle / focus / off-screen shapes).
|
||||||
|
|
||||||
|
## Open question carried into the plan
|
||||||
|
|
||||||
|
Build order. Recommended: (1) rename in place (mechanical, keeps tests green),
|
||||||
|
(2) extract harness + `Mode` with focus as the only mode (tests green proves
|
||||||
|
the extraction), (3) add off-screen mode, (4) reshape the frontend under the
|
||||||
|
envelope. Each step leaves the daemon runnable.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# Ambient Drift Coach — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Scope:** Give Keel an always-on presence. Today Keel only coaches *inside* a
|
||||||
|
declared focus session; the moment you are just working, the harness goes idle,
|
||||||
|
drops every window snapshot, and the status bar reads `idle`. This slice adds an
|
||||||
|
**ambient drift coach**: a sentinel that watches the window stream continuously
|
||||||
|
(even when no mode is active), judges your recent activity against the broad
|
||||||
|
`~/owc` frame (goals, values, life-domains) plus today's Marvin tasks, and —
|
||||||
|
when you have clearly slid off what matters — surfaces a single coaching line to
|
||||||
|
the status bar, a `notify-send` toast, and a web-UI banner you can act on.
|
||||||
|
|
||||||
|
This builds the **Control loop / scheduler** component from
|
||||||
|
`docs/keel-architecture.md` §3, which today has zero implementation, and is the
|
||||||
|
first thing that makes Keel *show up unprompted* (SOUL: "when my partner drifts,
|
||||||
|
I name it").
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The focus mode is already a capable proactive coach — local on-task matching, an
|
||||||
|
LLM drift judge, a semantic "nudge" over recent window titles, surfaced to the
|
||||||
|
status bar and web UI, all sensibly debounced. But every bit of it hangs off a
|
||||||
|
**declared `Commitment`**. You must open the UI and start a session first. With
|
||||||
|
no session:
|
||||||
|
|
||||||
|
- `harness.RecordWindow` finds no active mode and silently drops the snapshot.
|
||||||
|
- `statusfile.Render` of an empty envelope returns `idle`.
|
||||||
|
- No brain call ever happens; Keel is blind and mute.
|
||||||
|
|
||||||
|
So the common case — Felix working without ceremonially declaring a task — gets
|
||||||
|
nothing. The fix is an observer that runs regardless of the active mode and is
|
||||||
|
grounded in the standing frame instead of a per-session commitment.
|
||||||
|
|
||||||
|
## Approach (and the one real fork)
|
||||||
|
|
||||||
|
The ambient coach is **not a mode**. It is an always-on **sentinel** that runs
|
||||||
|
*beside* the one-mode harness. Two harness invariants make "ambient as a mode"
|
||||||
|
the wrong shape:
|
||||||
|
|
||||||
|
1. The harness hosts exactly one mode and drops evidence when idle — an ambient
|
||||||
|
watcher must run continuously, including when idle.
|
||||||
|
2. `harness.Start` returns `ErrBusy` if a different mode is active — an ambient
|
||||||
|
mode would either block you from starting focus, or need a special
|
||||||
|
auto-yield carve-out that erodes the single-mode contract.
|
||||||
|
|
||||||
|
A sentinel sidesteps both and matches §3, where the control loop is a distinct
|
||||||
|
component beside the harness. **Coexistence rule:** when any harness mode is
|
||||||
|
active (`h.State().ActiveMode != ""`), the sentinel stays silent — focus mode
|
||||||
|
already owns coaching during a declared session, so there is never double
|
||||||
|
coaching.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Each unit has one responsibility, a defined interface, and is testable alone.
|
||||||
|
|
||||||
|
### 1. `internal/notify` — notification effector (new)
|
||||||
|
|
||||||
|
The OS-level "interrupt me" primitive, mirroring `enforce.Guard`.
|
||||||
|
|
||||||
|
- **Interface:** `Notifier interface { Notify(ctx context.Context, title, body string) error }`
|
||||||
|
- **Linux adapter:** shells `notify-send <title> <body>`. Best-effort: returns
|
||||||
|
an error for diagnostics; callers never block on it.
|
||||||
|
- **Constructor:** `NewNotifier() Notifier` returns the real adapter when the
|
||||||
|
`notify-send` binary is found on `PATH` (via `exec.LookPath`), else a nop. On
|
||||||
|
non-Linux it is a nop. This means a missing binary degrades to "no toast,"
|
||||||
|
never a failure.
|
||||||
|
- **Nop:** `Notify` returns nil, does nothing.
|
||||||
|
- **Depends on:** `os/exec` only. Leaf package.
|
||||||
|
|
||||||
|
### 2. `internal/frame` — shared frame assembler (new; DRY extraction)
|
||||||
|
|
||||||
|
The "what matters to Felix" brief, today duplicated inside off-screen's
|
||||||
|
`collect.go`. Extracting it removes the duplication and gives the sentinel the
|
||||||
|
same grounding off-screen uses.
|
||||||
|
|
||||||
|
- **Interface:** `Assemble(ctx context.Context, know knowledge.Source, tasks tasks.Provider) string`
|
||||||
|
returns a bounded markdown brief with three sections: **Today's tasks**
|
||||||
|
(from `tasks.Today`), **Goals** (`~/owc/goals-2026.md` via `knowledge.Load`),
|
||||||
|
and **Life domains** (every `~/owc/resources/bug-*.md` via `knowledge.Load`).
|
||||||
|
- **Best-effort:** never panics, never errors; tolerates nil ports and missing
|
||||||
|
files, degrading to a short, mostly-empty brief. Same truncation cap
|
||||||
|
(`maxBriefBytes`) and rune-safe clipping off-screen uses today.
|
||||||
|
- **Refactor:** off-screen's `briefTasks` / `briefGoals` / `briefLifeDomains`
|
||||||
|
are replaced by a call to `frame.Assemble`; off-screen keeps its own
|
||||||
|
proposal-history section (`briefHistory`) prepended around it. Off-screen's
|
||||||
|
existing tests must stay green.
|
||||||
|
- **Depends on:** `knowledge`, `tasks`. Imports nothing from `ai`/`mode`.
|
||||||
|
|
||||||
|
### 3. `internal/ai` — `AmbientDrift` method (new; mirrors `Nudge`)
|
||||||
|
|
||||||
|
The brain call grounded in the frame, not a commitment.
|
||||||
|
|
||||||
|
- **Method:** `func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)`
|
||||||
|
returns `""` when the activity plausibly serves the frame (on-track), and a
|
||||||
|
one-sentence coaching line when it is a slide.
|
||||||
|
- **Prompt:** an "ambient coach" prompt stating the user has **not** declared a
|
||||||
|
task; it is handed the frame and the recent window-title sequence and asked
|
||||||
|
whether the activity serves **any** of what matters, **forgivingly** —
|
||||||
|
legitimate breaks, rest, and unplanned-but-useful work are on-track. Output is
|
||||||
|
`ONLY` JSON: `{"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}`.
|
||||||
|
- **Parse (`parseAmbientDrift`):** reuses `extractJSON` and the shared
|
||||||
|
`ErrEmptyResponse` / `ErrNoJSON` sentinels. `drifting:false` → `""`.
|
||||||
|
`drifting:true` with a message → the message. `drifting:true` with no message →
|
||||||
|
`""` (silence) — the ambient signal is advisory, so the safe degenerate is to
|
||||||
|
say nothing, exactly like `parseNudge`.
|
||||||
|
- **Depends on:** the existing `Backend`. `ai` stays a leaf (primitives only).
|
||||||
|
|
||||||
|
### 4. `internal/ambient` — the Sentinel (new; the meat)
|
||||||
|
|
||||||
|
Always-on observer: consume evidence → tick on cadence → judge → surface.
|
||||||
|
|
||||||
|
**Construction:** `New(deps)` where deps carry `*ai.Service`, `knowledge.Source`,
|
||||||
|
`tasks.Provider`, `notify.Notifier`, `memory.Store`, `clock func() time.Time`,
|
||||||
|
and `activeMode func() string` (reads `h.State().ActiveMode` for the coexistence
|
||||||
|
rule). Config: `cadence time.Duration` and `mode` (`off` | `status` | `notify`).
|
||||||
|
|
||||||
|
**State (under one mutex):** `recentTitles []string` (consecutive-dedup ring,
|
||||||
|
capped like focus's `recentTitlesMax`), `lastWindow evidence.WindowSnapshot`,
|
||||||
|
`line string` (current drift line; `""` when on-track), `lastEvalTitles`
|
||||||
|
(snapshot of the ring at the last brain call), `wasDrifting bool`,
|
||||||
|
`episodeNotified bool`, `snoozeUntil time.Time`, `gen int`, and `onChange []func()`.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
|
||||||
|
- `OnWindow(snap evidence.WindowSnapshot)` — called from the evidence fan-out.
|
||||||
|
Updates `lastWindow`, appends the title to the ring (skip consecutive dup,
|
||||||
|
cap). Cheap and lock-bounded; fires no brain call.
|
||||||
|
- `Run(ctx context.Context)` — cadence loop: a ticker every `cadence` calls
|
||||||
|
`evaluate()`; returns on ctx cancel.
|
||||||
|
- `evaluate()` — the decision (see Trigger discipline below).
|
||||||
|
- `Line() string` — current ambient line for the surfaces (`""` when on-track,
|
||||||
|
quiet, or snoozed).
|
||||||
|
- `Snooze(d time.Duration)` — sets `snoozeUntil = clock()+d`; clears the line and
|
||||||
|
episode flags; fires `onChange`. While snoozed, `evaluate()` returns early.
|
||||||
|
- `SetConfig(cadence time.Duration, mode string)` — live settings update.
|
||||||
|
- `AddOnChange(f func())` — register a surface refresh (status wake, web
|
||||||
|
broadcast). Fired whenever the line changes.
|
||||||
|
|
||||||
|
**Depends on:** `ai`, `frame`, `notify`, `memory`, `evidence`, and a
|
||||||
|
`activeMode` closure. It does **not** import `harness` (closure injection keeps
|
||||||
|
it decoupled and testable).
|
||||||
|
|
||||||
|
### 5. Wiring — `cmd/keeld/main.go`
|
||||||
|
|
||||||
|
- **Build** the notifier (`notify.NewNotifier()`) and the sentinel from settings
|
||||||
|
(cadence, mode) and the shared ports (the same `ai.Service`, `knowledge`,
|
||||||
|
`tasks`, `memory`, clock used to build `harness.Services`), with
|
||||||
|
`activeMode: func() string { return h.State().ActiveMode }`.
|
||||||
|
- **Fan the evidence stream to both** the harness and the sentinel:
|
||||||
|
```go
|
||||||
|
go src.Watch(ctx, func(w evidence.WindowSnapshot) {
|
||||||
|
h.RecordWindow(w)
|
||||||
|
sentinel.OnWindow(w)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- **Run** the sentinel: `go sentinel.Run(ctx)`.
|
||||||
|
- **Surface refresh:** register both the status writer's `Wake` and the web
|
||||||
|
broadcast with `sentinel.AddOnChange(...)`, so a new ambient line re-renders
|
||||||
|
the bar and pushes over SSE promptly.
|
||||||
|
- **Live settings:** `applyFn` (the existing POST `/settings` re-wire) also calls
|
||||||
|
`sentinel.SetConfig(...)` from the new settings, so the dial takes effect
|
||||||
|
without a restart.
|
||||||
|
|
||||||
|
## Trigger discipline (aggressive, but not trigger-happy)
|
||||||
|
|
||||||
|
A `notify-send` toast on a false positive is far more irritating than a stale
|
||||||
|
status line, so `evaluate()` is conservative and brain-call-thrifty:
|
||||||
|
|
||||||
|
1. If `mode == off` → return.
|
||||||
|
2. If snoozed (`clock() < snoozeUntil`) → return.
|
||||||
|
3. If `activeMode() != ""` → a mode is coaching; clear the line (if set), fire
|
||||||
|
`onChange`, return. (Coexistence rule.)
|
||||||
|
4. **Cheap gate:** if the ring is unchanged since `lastEvalTitles` **and** we are
|
||||||
|
currently on-track (`line == ""`) → skip the brain call. This naturally
|
||||||
|
pauses calls during steady deep work *and* while AFK (no new titles arrive in
|
||||||
|
either case), while still re-checking an unresolved drift.
|
||||||
|
5. Assemble the frame (`frame.Assemble`) and call
|
||||||
|
`ai.AmbientDrift(ctx, frame, recentTitles)` under a timeout.
|
||||||
|
6. Apply the result:
|
||||||
|
- **On-track (`""`):** if a line was set, clear it and reset
|
||||||
|
`wasDrifting=false`, `episodeNotified=false`; fire `onChange`. Otherwise no-op.
|
||||||
|
- **Drifting (`msg`):**
|
||||||
|
- **New episode** (`!wasDrifting`): set `line=msg`, `wasDrifting=true`,
|
||||||
|
`episodeNotified=false`; fire `onChange`. The **status bar / banner show
|
||||||
|
immediately**, but **no toast yet**.
|
||||||
|
- **Sustained** (already `wasDrifting` on this consecutive eval): record an
|
||||||
|
`ambient_nudge` event to `memory` (`{ "message": msg, "title":
|
||||||
|
lastWindow.Title, "class": lastWindow.Class }`), and — if `mode == notify`
|
||||||
|
and `!episodeNotified` — fire exactly one `notify-send` toast and set
|
||||||
|
`episodeNotified=true`. Further sustained ticks do not re-toast.
|
||||||
|
- On a brain **error**, leave the prior line intact and log — never fabricate
|
||||||
|
drift (same discipline as `Nudge`).
|
||||||
|
7. Update `lastEvalTitles` to the current ring.
|
||||||
|
|
||||||
|
Net effect with the default ~5-minute cadence: a 40-second glance at Twitter
|
||||||
|
never pops a toast (it resolves before the second tick); a sustained ~10-minute
|
||||||
|
slide shows on the bar at the first tick and pops one toast at the second.
|
||||||
|
Memory records only *sustained* drifts, independent of the surface mode.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
- **Status bar (`internal/statusfile`):** `Render` gains the current ambient line
|
||||||
|
(passed in by the `Writer`, which reads `sentinel.Line`). When the envelope is
|
||||||
|
idle (`ActiveMode == ""`): a non-empty line renders `⚠ <line>`; an empty line
|
||||||
|
renders `idle` as today. Non-idle envelopes are unchanged. The `Writer` adds an
|
||||||
|
`ambientLine func() string` source and `sentinel.AddOnChange(writer.Wake)`
|
||||||
|
drives prompt re-render; the existing minute ticker still applies.
|
||||||
|
- **Web UI (`internal/web` + `static/app.js`):** the served state gains an
|
||||||
|
`"ambient": "<line>"` field (read from `sentinel.Line`), pushed over the
|
||||||
|
existing SSE channel via `sentinel.AddOnChange(broadcast)`. `app.js` renders a
|
||||||
|
**banner** when the field is non-empty: the coaching line plus two controls —
|
||||||
|
**Snooze 1h** (`POST /ambient/snooze`) and **Start focus** (reuses the existing
|
||||||
|
start-session affordance so a nudge converts into a declared session). The
|
||||||
|
banner clears automatically when the line goes empty.
|
||||||
|
- **New endpoint:** `POST /ambient/snooze` → `sentinel.Snooze(1 * time.Hour)`.
|
||||||
|
Returns 204. No body needed in v1.
|
||||||
|
|
||||||
|
## Config (the agency-vs-cage dial, §8)
|
||||||
|
|
||||||
|
`settings.Settings` gains two fields (env-seedable, mirroring the existing ones):
|
||||||
|
|
||||||
|
- `ambient_mode` (`KEEL_AMBIENT_MODE`): `off` | `status` | `notify`. Default
|
||||||
|
`notify`. Validated in the settings applier (like `ai_backend`); an unknown
|
||||||
|
value is rejected with a wrapped sentinel so POST `/settings` can map it to 400.
|
||||||
|
- `ambient_cadence_secs` (`KEEL_AMBIENT_CADENCE_SECS`): integer seconds between
|
||||||
|
evaluations. Default `300`. A non-positive value falls back to the default.
|
||||||
|
|
||||||
|
The web settings form gains a select (`ambient_mode`) and a number input
|
||||||
|
(`ambient_cadence_secs`); `applyFn` pushes both into `sentinel.SetConfig`.
|
||||||
|
|
||||||
|
## Error handling & graceful degradation
|
||||||
|
|
||||||
|
- **No `notify-send` binary / non-Linux:** nop notifier; status line and banner
|
||||||
|
still work.
|
||||||
|
- **Brain error / timeout:** `AmbientDrift` returns an error; the sentinel logs
|
||||||
|
and leaves the prior line intact. Never fabricates drift.
|
||||||
|
- **AW down:** `memory` is already the nop store; `ambient_nudge` writes are
|
||||||
|
dropped silently.
|
||||||
|
- **`knowledge` / `tasks` errors:** `frame.Assemble` degrades to a partial brief
|
||||||
|
(best-effort), never errors.
|
||||||
|
- **Headless / no X11:** the evidence source is a no-op, so `OnWindow` is never
|
||||||
|
called, the ring stays empty, the cheap gate skips every brain call, and the
|
||||||
|
sentinel is silent. No new X11 requirement.
|
||||||
|
- **Nothing in the sentinel can crash the daemon** — every external call is
|
||||||
|
best-effort and lock-bounded.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **`internal/ambient`** (fake clock, fake `ai` with scriptable `AmbientDrift`
|
||||||
|
returns and a call counter, fake `notify.Notifier` recording calls, fake
|
||||||
|
`tasks`/`knowledge`, `activeMode` stub, `memory.Fake`):
|
||||||
|
- on-track → no line, no toast, no memory.
|
||||||
|
- single drift tick → line set, **no** toast, no memory yet.
|
||||||
|
- two consecutive drift ticks → line set, **exactly one** toast, one
|
||||||
|
`ambient_nudge` event.
|
||||||
|
- third consecutive drift tick → still one toast (once per episode).
|
||||||
|
- back on-track → line cleared, flags reset.
|
||||||
|
- `activeMode() != ""` → no eval, line cleared.
|
||||||
|
- `mode == status` → drift sets the line but never toasts (memory still records
|
||||||
|
the sustained drift).
|
||||||
|
- `mode == off` → nothing happens, no brain call.
|
||||||
|
- snooze → no eval / no line during the window.
|
||||||
|
- cheap gate → unchanged ring + on-track → assert the fake `ai` call count does
|
||||||
|
**not** increase.
|
||||||
|
- **`internal/notify`:** `NewNotifier` with `notify-send` absent → nop; `Notify`
|
||||||
|
returns nil, no panic. (The real shell path is not unit-tested.)
|
||||||
|
- **`internal/ai`:** `parseAmbientDrift` — on-track→`""`, drift+msg→msg, drift
|
||||||
|
with no msg→`""`, empty→`ErrEmptyResponse`, no-JSON→`ErrNoJSON`.
|
||||||
|
- **`internal/frame`:** `Assemble` with fake ports — sections present; nil ports
|
||||||
|
and missing files degrade without error; truncation honored. Off-screen's
|
||||||
|
existing tests stay green after the refactor.
|
||||||
|
- **`internal/statusfile`:** idle + ambient line → `⚠ …`; idle + empty line →
|
||||||
|
`idle`; non-idle envelopes unaffected.
|
||||||
|
- **`internal/web`:** served state includes `ambient`; `POST /ambient/snooze`
|
||||||
|
routes to `Snooze` and returns 204.
|
||||||
|
- **`internal/settings`:** `ambient_mode` validation (good values accepted,
|
||||||
|
unknown rejected with the sentinel); cadence default on non-positive.
|
||||||
|
|
||||||
|
## Out of scope (deliberate, for a later slice)
|
||||||
|
|
||||||
|
- **AFK precision via the AW afk bucket.** v1 uses the cheap-gate heuristic
|
||||||
|
(no new titles ⇒ no call), which covers both AFK and deep-focus well enough.
|
||||||
|
Reading AW's input-derived afk bucket to *prove* presence is a future collector.
|
||||||
|
- **Ambient enforcement** (window-minimize). The sentinel is advisory only;
|
||||||
|
minimizing windows with no declared session would be far too cagey. Enforcement
|
||||||
|
stays a focus-session decision.
|
||||||
|
- **Trend / spiral detection** over the accumulating `ambient_nudge` history
|
||||||
|
(§7). This slice *produces* that history; reading it for cross-time patterns is
|
||||||
|
the next increment.
|
||||||
|
- **Richer banner actions** beyond snooze + start-focus (e.g. "I'm on it",
|
||||||
|
per-domain mute).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Default cadence.** 300 s is a starting guess; may want tuning after living
|
||||||
|
with it. Configurable, so cheap to change.
|
||||||
|
- **Snooze duration.** Fixed 1 h in v1; a picker is a later refinement.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Off-screen Memory — AW-backed Recall — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved, ready for implementation planning
|
||||||
|
**Scope:** Give Keel its first durable, cross-run memory by wiring **one** mode
|
||||||
|
(off-screen) to a real ActivityWatch bucket. Off-screen records its own
|
||||||
|
decisions (`proposal_made` / `action_taken` / `proposal_dismissed`) to
|
||||||
|
`keel.events` and reads its recent history back into the next brief, so
|
||||||
|
proposals stay fresh and follow up on what was not acted on. The memory layer
|
||||||
|
is introduced as a thin reusable port (`memory.Store`) behind which AW, HTTP,
|
||||||
|
and bucket names are sealed — the seam every later mode reuses.
|
||||||
|
|
||||||
|
This realizes the "Next increment: memory" note in
|
||||||
|
`docs/keel-architecture.md` §5 and the storage design in §7, at the smallest
|
||||||
|
honest vertical.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Keel has no durable memory. Mode state dies in `~/.keel/.../state.json`; the
|
||||||
|
off-screen mode cannot remember what it proposed last time, so it can repeat
|
||||||
|
itself and can never follow up ("you said you'd walk before the call — did
|
||||||
|
you?"). The architecture (§7) resolves storage to **ActivityWatch buckets**
|
||||||
|
(no new DB), because AW is an append-only, timestamped, queryable event store —
|
||||||
|
the substrate that later unlocks trend/spiral detection no single tool can do.
|
||||||
|
Today, zero AW client code exists in the repo and no `keel.*` bucket exists.
|
||||||
|
|
||||||
|
This design builds the first real slice of that memory, and only that slice.
|
||||||
|
|
||||||
|
## Decisions (locked in brainstorming 2026-06-05)
|
||||||
|
|
||||||
|
1. **Slice width = thin port + one consumer.** Add a generic `memory.Store`
|
||||||
|
port to `harness.Services` (write an event, query recent), backed by AW, but
|
||||||
|
wire exactly one real behavior: off-screen remembers. Focus and other event
|
||||||
|
kinds come later.
|
||||||
|
2. **Behavior = fresh + follow-up.** The brief includes recent proposals *and*
|
||||||
|
their outcome (confirmed / dismissed / unactioned). The brain is told to
|
||||||
|
avoid blindly repeating and to follow up on a recent unactioned/dismissed one
|
||||||
|
when it still fits.
|
||||||
|
3. **Brain-only (no UI this slice).** Memory shapes the proposal; nothing new is
|
||||||
|
rendered. A "recently proposed" UI panel is a trivial later follow-up once the
|
||||||
|
data is flowing.
|
||||||
|
4. **AW from the start, behind a port.** Not the existing JSON `store` package —
|
||||||
|
§3/§7 lock storage to AW precisely for queryability. Modes never touch HTTP.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two new leaf packages, matching the existing ports-and-adapters style
|
||||||
|
(`evidence.Source`, `knowledge.Source`, `tasks.Provider`, `ai.Backend`,
|
||||||
|
`enforce.Guard`):
|
||||||
|
|
||||||
|
### `internal/aw` — thin AW REST client
|
||||||
|
|
||||||
|
No Keel concepts; just buckets and events. Grounded in the live API
|
||||||
|
(`/api/0`, AW server v0.13.2): events are `{id, timestamp, duration, data{}}`,
|
||||||
|
and `GET .../events?limit=N` returns newest-first.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Duration float64
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct { /* baseURL, *http.Client with timeout */ }
|
||||||
|
|
||||||
|
func New(baseURL string) *Client
|
||||||
|
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error // idempotent
|
||||||
|
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error // POST .../events
|
||||||
|
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) // GET .../events?limit=N
|
||||||
|
```
|
||||||
|
|
||||||
|
- `EnsureBucket` is idempotent: creating an existing bucket is treated as
|
||||||
|
success (AW returns a benign "already exists" response).
|
||||||
|
- `Insert` posts a single event (`duration: 0` for discrete derived events).
|
||||||
|
- Any transport/HTTP-status error is returned; callers treat memory as
|
||||||
|
best-effort.
|
||||||
|
|
||||||
|
### `internal/memory` — Keel-facing port + adapters
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event struct {
|
||||||
|
Kind string // "proposal_made", "action_taken", "proposal_dismissed"
|
||||||
|
At time.Time
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Record(ctx context.Context, e Event) error
|
||||||
|
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `awStore` implements `Store` over `*aw.Client`, bucket id `keel.events`. It
|
||||||
|
maps `memory.Event ↔ aw.Event`, carrying `Kind` inside `data._kind` and the
|
||||||
|
rest of `Data` alongside. Because `keel.events` interleaves all kinds,
|
||||||
|
`Recent(kind, n)` **over-fetches** a bounded window of recent bucket events
|
||||||
|
(a fixed cap, e.g. 200), filters by `_kind` client-side, and returns up to `n`
|
||||||
|
newest. Events older than that window are not seen — acceptable for "recent",
|
||||||
|
and revisited if/when the event volume grows.
|
||||||
|
- `nopStore` — `Record`/`Recent` no-ops; used when AW is unreachable/disabled.
|
||||||
|
- `fake` — in-memory `Store` for tests (records appended in memory, `Recent`
|
||||||
|
filters and returns newest-first).
|
||||||
|
|
||||||
|
### Wiring
|
||||||
|
|
||||||
|
- Add `Memory memory.Store` to `harness.Services`.
|
||||||
|
- `cmd/keeld.buildServices` constructs the AW-backed store: base URL from a new
|
||||||
|
`KEEL_AW_URL` setting (default `http://localhost:5600`), then
|
||||||
|
`EnsureBucket("keel.events", "keel.event", "keel")` **once at startup**. If
|
||||||
|
that fails, log once and fall back to `nopStore` so a missing/down AW never
|
||||||
|
breaks boot.
|
||||||
|
- `offscreen.New` / `offscreen.Factory` receive `svc.Memory`.
|
||||||
|
|
||||||
|
Modes depend only on the `memory.Store` interface; AW, HTTP, and bucket names
|
||||||
|
stay sealed inside the adapter — same shape as every other port.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`keel.events` is an append-only log of Keel's **derived** events. This slice
|
||||||
|
writes three kinds, correlated by a `proposal_id` (short random hex minted when
|
||||||
|
a proposal is made; `crypto/rand`, in the daemon, not the sandbox):
|
||||||
|
|
||||||
|
| Kind | When | `data` |
|
||||||
|
|---|---|---|
|
||||||
|
| `proposal_made` | brain returns a proposal | `{proposal_id, mode:"offscreen", next_action, rationale}` |
|
||||||
|
| `action_taken` | confirm succeeds | `{proposal_id, mode:"offscreen", next_action}` |
|
||||||
|
| `proposal_dismissed` | dismiss | `{proposal_id, mode:"offscreen"}` |
|
||||||
|
|
||||||
|
**Reference, don't duplicate (§7):** `next_action` is Keel's *own* output, not
|
||||||
|
another tool's source of truth — recording it logs Keel's decision, it does not
|
||||||
|
duplicate Marvin. The Marvin task id is *not* captured because
|
||||||
|
`tasks.Provider.Create` returns only `error`; surfacing the real id is a future
|
||||||
|
enhancement gated on that signature and is out of scope here.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Write path (all best-effort)
|
||||||
|
|
||||||
|
A memory write failure is logged and swallowed; it never fails the command.
|
||||||
|
|
||||||
|
- Proposal produced (async `onComplete`, status → `proposed`): mint a
|
||||||
|
`proposal_id`, stash it on the mode, `Record(proposal_made)`.
|
||||||
|
- `confirm()`: after `tasks.Create` succeeds, `Record(action_taken)`.
|
||||||
|
- `dismiss()`: `Record(proposal_dismissed)`.
|
||||||
|
|
||||||
|
### Read path
|
||||||
|
|
||||||
|
`offscreen.assembleBrief()` gains a `## Recently proposed` section:
|
||||||
|
|
||||||
|
1. `Recent("proposal_made", 5)` (newest-first); drop entries older than ~7 days.
|
||||||
|
2. For each, resolve outcome by matching `proposal_id` against recent
|
||||||
|
`action_taken` / `proposal_dismissed` → label `confirmed` / `dismissed` /
|
||||||
|
`unactioned`, with a relative age.
|
||||||
|
3. Render lines, e.g. `- "Walk before the call" (confirmed, 2h ago)`.
|
||||||
|
4. `buildProposePrompt` gains a rule: *don't repeat these; if a recent one was
|
||||||
|
dismissed or unactioned and still fits, follow up on it instead of inventing
|
||||||
|
new.*
|
||||||
|
|
||||||
|
If `Memory` is nil or `Recent` errors, the section is omitted — the proposal
|
||||||
|
still happens, ungrounded by history.
|
||||||
|
|
||||||
|
## Error handling / degradation
|
||||||
|
|
||||||
|
Memory is never on the critical path; it degrades exactly like the `knowledge`
|
||||||
|
and `tasks` ports already do in this mode.
|
||||||
|
|
||||||
|
- AW down at **startup** → `EnsureBucket` fails → log once, use `nopStore`.
|
||||||
|
Off-screen works, unremembering.
|
||||||
|
- AW down **mid-run** → `Record`/`Recent` error → logged, swallowed.
|
||||||
|
Propose / confirm / dismiss all still succeed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `internal/aw` — against an `httptest` server: asserts the POST event body and
|
||||||
|
the `GET ?limit=N` round-trip; an error status surfaces as an error.
|
||||||
|
- `internal/memory` — `awStore` against `httptest` (the `_kind` mapping;
|
||||||
|
`Recent` filters by kind); `nopStore` trivial; the `fake` exercised directly.
|
||||||
|
- `internal/mode/offscreen` — using the `fake` store: (a) `proposal_made`
|
||||||
|
written on propose, (b) `action_taken` on confirm, (c) `proposal_dismissed`
|
||||||
|
on dismiss, (d) brief contains recent proposals with outcome labels, (e)
|
||||||
|
everything still works with a nil/erroring store. Existing offscreen tests get
|
||||||
|
the `fake` (or nil) injected — no behavior regressions.
|
||||||
|
|
||||||
|
## Out of scope (named to prevent scope creep)
|
||||||
|
|
||||||
|
- No `keel.state` bucket — only `keel.events`.
|
||||||
|
- No focus-mode events; no `mode_started` / `feedback_given` / `spiral_flagged`
|
||||||
|
/ `coach_line`.
|
||||||
|
- No UI rendering of history (brain-only).
|
||||||
|
- No spiral/trend detection — the *reason* for AW, but a later slice that reads
|
||||||
|
this data.
|
||||||
|
- No cross-device AW sync, no Marvin-id capture, no assembler privacy-filter
|
||||||
|
rework.
|
||||||
|
|
||||||
|
## What ships
|
||||||
|
|
||||||
|
Off-screen mode that writes its decisions to a real AW bucket and reads its own
|
||||||
|
history back into the next proposal — fresher proposals that follow up on what
|
||||||
|
was not done. The first durable-memory vertical, end to end, and the seam every
|
||||||
|
later mode reuses.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module antidrift
|
module keel
|
||||||
|
|
||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jezek/xgb v1.3.1
|
github.com/jezek/xgb v1.3.1
|
||||||
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
||||||
|
golang.org/x/sys v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -36,7 +37,6 @@ require (
|
|||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// internal/ai/ambient.go
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientDriftJudge judges whether current activity serves the user's standing
|
||||||
|
// frame when NO task is declared. Like Nudge it takes primitives, not domain
|
||||||
|
// types, so ai stays a leaf. The returned string is a one-sentence advisory, or
|
||||||
|
// "" when the activity is plausibly on-track.
|
||||||
|
type AmbientDriftJudge interface {
|
||||||
|
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response.
|
||||||
|
var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift")
|
||||||
|
|
||||||
|
// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend.
|
||||||
|
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parseAmbientDrift(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAmbientPrompt(frame string, recentTitles []string) string {
|
||||||
|
return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame — goals, values, and life-domains — and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift.
|
||||||
|
|
||||||
|
Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"drifting": <true or false>, "message": "<short explanation, one sentence>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- drifting: true only if the recent activity serves none of the frame; false otherwise.
|
||||||
|
- message: one short sentence naming the drift. REQUIRED when drifting is true.
|
||||||
|
|
||||||
|
## Frame
|
||||||
|
` + frame + `
|
||||||
|
|
||||||
|
## Recent window titles (oldest to newest)
|
||||||
|
` + strings.Join(recentTitles, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawAmbientDrift struct {
|
||||||
|
Drifting bool `json:"drifting"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields
|
||||||
|
// "". A drift with no message degrades to "" (silence) rather than an error:
|
||||||
|
// the ambient signal is advisory, so the safe degenerate is to say nothing —
|
||||||
|
// exactly like parseNudge.
|
||||||
|
func parseAmbientDrift(s string) (string, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return "", ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||||
|
}
|
||||||
|
var raw rawAmbientDrift
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||||
|
}
|
||||||
|
if !raw.Drifting {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.Message), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// internal/ai/ambient_test.go
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceAmbientDriftSuccess(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`}
|
||||||
|
line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ambient drift: %v", err)
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
t.Fatal("expected a non-empty drift line")
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "ship keel") {
|
||||||
|
t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "YouTube - Brave") {
|
||||||
|
t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceAmbientDriftBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAmbientDrift(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"on track yields empty", `{"drifting": false, "message": ""}`, "", nil},
|
||||||
|
{"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil},
|
||||||
|
{"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil},
|
||||||
|
{"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil},
|
||||||
|
{"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil},
|
||||||
|
{"empty response", " ", "", ErrEmptyResponse},
|
||||||
|
{"no json", "no braces here", "", ErrNoJSON},
|
||||||
|
{"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseAmbientDrift(tt.in)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
|
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
|
||||||
f, err := os.CreateTemp("", "antidrift-codex-*.out")
|
f, err := os.CreateTemp("", "keel-codex-*.out")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("codex: temp file: %w", err)
|
return "", fmt.Errorf("codex: temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OffscreenProposal is the brain's suggestion for one worthwhile thing to do
|
||||||
|
// away from the screen. It is deliberately distinct from Proposal (the focus
|
||||||
|
// coach's commitment shape) to avoid a name collision.
|
||||||
|
type OffscreenProposal struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proposer turns an off-screen brief (goals, life-domain notes, today's tasks)
|
||||||
|
// into a single grounded off-screen action.
|
||||||
|
type Proposer interface {
|
||||||
|
Propose(ctx context.Context, brief string) (OffscreenProposal, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propose makes Service satisfy Proposer over the same backend as Coach.
|
||||||
|
func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildProposePrompt(brief))
|
||||||
|
if err != nil {
|
||||||
|
return OffscreenProposal{}, err
|
||||||
|
}
|
||||||
|
return parseOffscreenProposal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProposePrompt(brief string) string {
|
||||||
|
return `You are an off-screen advisor. Read the brief below — the user's goals, life-domain notes, and today's tasks — and pick ONE worthwhile thing for them to do now, away from the screen.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"next_action": "<one worthwhile off-screen action to do now>", "rationale": "<one short sentence: which goal or value it serves>"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- next_action: a single concrete off-screen action, doable now.
|
||||||
|
- rationale: one short sentence naming the goal or value it serves.
|
||||||
|
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
|
||||||
|
|
||||||
|
## Brief
|
||||||
|
` + brief
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOffscreenProposal extracts and validates an OffscreenProposal from raw
|
||||||
|
// CLI output, mirroring parseProposal's error discipline.
|
||||||
|
func parseOffscreenProposal(s string) (OffscreenProposal, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return OffscreenProposal{}, ErrEmptyResponse
|
||||||
|
}
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return OffscreenProposal{}, ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
|
||||||
|
}
|
||||||
|
var raw OffscreenProposal
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
|
||||||
|
}
|
||||||
|
na := strings.TrimSpace(raw.NextAction)
|
||||||
|
if na == "" {
|
||||||
|
return OffscreenProposal{}, ErrInvalidProposal
|
||||||
|
}
|
||||||
|
return OffscreenProposal{
|
||||||
|
NextAction: na,
|
||||||
|
Rationale: strings.TrimSpace(raw.Rationale),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProposeParsesProposal(t *testing.T) {
|
||||||
|
svc := NewService(&fakeBackend{out: `{"next_action":"call mum","rationale":"people goal"}`})
|
||||||
|
p, err := svc.Propose(context.Background(), "off-screen brief")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Propose: %v", err)
|
||||||
|
}
|
||||||
|
if p.NextAction != "call mum" || p.Rationale != "people goal" {
|
||||||
|
t.Fatalf("proposal = %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).Propose(context.Background(), "brief"); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeUnparseable(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: "I cannot help."}
|
||||||
|
if _, err := NewService(fb).Propose(context.Background(), "brief"); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposePromptIncludesBrief(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `{"next_action":"a","rationale":"b"}`}
|
||||||
|
if _, err := NewService(fb).Propose(context.Background(), "water the tomato plants"); err != nil {
|
||||||
|
t.Fatalf("Propose: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "water the tomato plants") {
|
||||||
|
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposePromptHasFollowUpRule(t *testing.T) {
|
||||||
|
p := buildProposePrompt("brief")
|
||||||
|
if !strings.Contains(p, "do not simply repeat") {
|
||||||
|
t.Fatalf("prompt missing follow-up rule:\n%s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package evidence
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
||||||
@@ -19,7 +19,10 @@ func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, allowed := range ctx.WindowClasses {
|
for _, allowed := range ctx.WindowClasses {
|
||||||
if normalizeCasefolded(allowed) == candidate {
|
// Substring, not equality: a short token a user types ("brave") matches the
|
||||||
|
// longer real WM_CLASS ("Brave-browser"). Equality silently disabled the
|
||||||
|
// fast path whenever the two differed, routing every window to the judge.
|
||||||
|
if a := normalizeCasefolded(allowed); a != "" && strings.Contains(candidate, a) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package evidence
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
||||||
@@ -19,6 +19,20 @@ func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWindowClassMatchesSubstring(t *testing.T) {
|
||||||
|
// Users type a short app name ("brave") but the real WM_CLASS is longer
|
||||||
|
// ("Brave-browser"). The allowed token matching as a substring of the actual
|
||||||
|
// class keeps the local on-task fast path working instead of silently routing
|
||||||
|
// every window to the LLM judge.
|
||||||
|
ctx := domain.AllowedContext{WindowClasses: []string{"brave"}}
|
||||||
|
if !windowClassAllowed(ctx, "Brave-browser") {
|
||||||
|
t.Fatal("short allowed token should match the longer real class")
|
||||||
|
}
|
||||||
|
if windowClassAllowed(ctx, "firefox") {
|
||||||
|
t.Fatal("unrelated class must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWindowTitleMatchesSubstring(t *testing.T) {
|
func TestWindowTitleMatchesSubstring(t *testing.T) {
|
||||||
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
|
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
|
||||||
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
|
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
|
||||||
|
|||||||
@@ -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, " "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ func TestScrubTitle(t *testing.T) {
|
|||||||
{"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()
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ type FileSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewFileSource builds the adapter. defaultPath is used when Load receives an
|
// NewFileSource builds the adapter. defaultPath is used when Load receives an
|
||||||
// empty path; if it too is empty, Load falls back to ~/.antidrift/knowledge.md.
|
// empty path; if it too is empty, Load falls back to ~/.keel/knowledge.md.
|
||||||
func NewFileSource(defaultPath string) *FileSource {
|
func NewFileSource(defaultPath string) *FileSource {
|
||||||
return &FileSource{defaultPath: defaultPath}
|
return &FileSource{defaultPath: defaultPath}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) {
|
|||||||
return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil
|
return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve picks path, else the default, else ~/.antidrift/knowledge.md; expands
|
// resolve picks path, else the default, else ~/.keel/knowledge.md; expands
|
||||||
// a leading ~; and makes the result absolute for stable display.
|
// a leading ~; and makes the result absolute for stable display.
|
||||||
func (s *FileSource) resolve(path string) string {
|
func (s *FileSource) resolve(path string) string {
|
||||||
p := path
|
p := path
|
||||||
@@ -55,7 +55,7 @@ func (s *FileSource) resolve(path string) string {
|
|||||||
}
|
}
|
||||||
if p == "" {
|
if p == "" {
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
p = filepath.Join(home, ".antidrift", "knowledge.md")
|
p = filepath.Join(home, ".keel", "knowledge.md")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p = expandTilde(p)
|
p = expandTilde(p)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
|
||||||
|
// events and read them back with Recent; the AW-backed adapter persists them to
|
||||||
|
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
|
||||||
|
// them — this package stays a generic event store.
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/aw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one derived event Keel chooses to remember. Kind is the event type
|
||||||
|
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
|
||||||
|
// other tools' source of truth.
|
||||||
|
type Event struct {
|
||||||
|
Kind string
|
||||||
|
At time.Time
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store records derived events and reads recent ones back by kind.
|
||||||
|
type Store interface {
|
||||||
|
Record(ctx context.Context, e Event) error
|
||||||
|
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
|
||||||
|
type nopStore struct{}
|
||||||
|
|
||||||
|
// NewNop returns a Store that drops writes and returns no history.
|
||||||
|
func NewNop() Store { return nopStore{} }
|
||||||
|
|
||||||
|
func (nopStore) Record(context.Context, Event) error { return nil }
|
||||||
|
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
|
||||||
|
|
||||||
|
// Fake is an in-memory Store for tests.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFake returns an empty in-memory Store.
|
||||||
|
func NewFake() *Fake { return &Fake{} }
|
||||||
|
|
||||||
|
func (f *Fake) Record(_ context.Context, e Event) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.events = append(f.events, e)
|
||||||
|
f.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []Event
|
||||||
|
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
|
||||||
|
if f.events[i].Kind == kind {
|
||||||
|
out = append(out, f.events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns a snapshot of everything recorded, for test assertions.
|
||||||
|
func (f *Fake) Events() []Event {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]Event(nil), f.events...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// overfetch bounds how many recent bucket events awStore pulls before filtering
|
||||||
|
// by kind, because keel.events interleaves all kinds. Events older than this
|
||||||
|
// window are not seen — acceptable for "recent".
|
||||||
|
const overfetch = 200
|
||||||
|
|
||||||
|
// kindKey is the data field carrying the Event.Kind inside an AW event.
|
||||||
|
const kindKey = "_kind"
|
||||||
|
|
||||||
|
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
|
||||||
|
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
|
||||||
|
type AWClient interface {
|
||||||
|
Insert(ctx context.Context, bucketID string, e aw.Event) error
|
||||||
|
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type awStore struct {
|
||||||
|
c AWClient
|
||||||
|
bucketID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAWStore returns a Store that persists events to the given AW bucket.
|
||||||
|
func NewAWStore(c AWClient, bucketID string) Store {
|
||||||
|
return &awStore{c: c, bucketID: bucketID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awStore) Record(ctx context.Context, e Event) error {
|
||||||
|
data := make(map[string]any, len(e.Data)+1)
|
||||||
|
for k, v := range e.Data {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data[kindKey] = e.Kind
|
||||||
|
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Event
|
||||||
|
for _, ev := range raw {
|
||||||
|
if len(out) >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if k, _ := ev.Data[kindKey].(string); k == kind {
|
||||||
|
data := make(map[string]any, len(ev.Data))
|
||||||
|
for dk, dv := range ev.Data {
|
||||||
|
if dk != kindKey {
|
||||||
|
data[dk] = dv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: data})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keel/internal/aw"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
|
||||||
|
|
||||||
|
got, err := f.Recent(ctx, "proposal_made", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Data["n"] != "b" {
|
||||||
|
t.Fatalf("want newest-first [b,a], got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRecentRespectsLimit(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
|
||||||
|
}
|
||||||
|
got, _ := f.Recent(ctx, "proposal_made", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("limit not honored: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNopStoreIsInert(t *testing.T) {
|
||||||
|
s := NewNop()
|
||||||
|
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
|
||||||
|
t.Fatalf("nop Record: %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.Recent(context.Background(), "x", 5)
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("nop Recent = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
|
||||||
|
// recent list so we can test mapping and over-fetch filtering without HTTP.
|
||||||
|
type fakeAWClient struct {
|
||||||
|
inserted []aw.Event
|
||||||
|
recent []aw.Event // newest-first, mixed kinds
|
||||||
|
lastLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
|
||||||
|
f.inserted = append(f.inserted, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
|
||||||
|
f.lastLimit = limit
|
||||||
|
return f.recent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
|
||||||
|
fc := &fakeAWClient{}
|
||||||
|
s := NewAWStore(fc, "keel.events")
|
||||||
|
at := time.Unix(1000, 0).UTC()
|
||||||
|
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
|
||||||
|
t.Fatalf("Record: %v", err)
|
||||||
|
}
|
||||||
|
if len(fc.inserted) != 1 {
|
||||||
|
t.Fatalf("inserted %d", len(fc.inserted))
|
||||||
|
}
|
||||||
|
got := fc.inserted[0]
|
||||||
|
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
|
||||||
|
t.Fatalf("mapped event = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
|
||||||
|
fc := &fakeAWClient{recent: []aw.Event{
|
||||||
|
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
|
||||||
|
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
|
||||||
|
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
|
||||||
|
}}
|
||||||
|
s := NewAWStore(fc, "keel.events")
|
||||||
|
got, err := s.Recent(context.Background(), "proposal_made", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if fc.lastLimit != overfetch {
|
||||||
|
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
|
||||||
|
t.Fatalf("filtered = %+v", got)
|
||||||
|
}
|
||||||
|
if _, leaked := got[0].Data["_kind"]; leaked {
|
||||||
|
t.Fatalf("_kind leaked into returned Data: %+v", got[0].Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWStoreRecentCapsAtN(t *testing.T) {
|
||||||
|
var recent []aw.Event
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
|
||||||
|
}
|
||||||
|
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
|
||||||
|
got, _ := s.Recent(context.Background(), "proposal_made", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("cap not applied: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package domain holds the core commitment types and validation, ported from
|
// Package domain holds the core commitment types and validation. These are
|
||||||
// the original Rust implementation. These are pure data types with no I/O.
|
// pure data types with no I/O.
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
package session
|
package focus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
"keel/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"keel/internal/evidence"
|
||||||
"antidrift/internal/evidence"
|
"keel/internal/knowledge"
|
||||||
"antidrift/internal/knowledge"
|
"keel/internal/mode/focus/domain"
|
||||||
"antidrift/internal/store"
|
"keel/internal/store"
|
||||||
"antidrift/internal/tasks"
|
"keel/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestController(t *testing.T) (*Controller, string) {
|
func newTestController(t *testing.T) (*Mode, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
c, err := New(path)
|
c, err := New(path)
|
||||||
@@ -36,7 +37,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
|||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("enter planning: %v", err)
|
t.Fatalf("enter planning: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil {
|
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start commitment: %v", err)
|
t.Fatalf("start commitment: %v", err)
|
||||||
}
|
}
|
||||||
st := c.State()
|
st := c.State()
|
||||||
@@ -66,7 +67,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
|||||||
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil {
|
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil, domain.EnforcementWarn); err == nil {
|
||||||
t.Fatalf("empty next action should error")
|
t.Fatalf("empty next action should error")
|
||||||
}
|
}
|
||||||
if c.State().RuntimeState != domain.RuntimePlanning {
|
if c.State().RuntimeState != domain.RuntimePlanning {
|
||||||
@@ -81,7 +82,7 @@ func TestStateRestoresFromSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
|
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
|
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,7 +104,7 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
|
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
want := first.State().Commitment.DeadlineUnixSecs
|
want := first.State().Commitment.DeadlineUnixSecs
|
||||||
|
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
@@ -146,7 +147,7 @@ func TestAccumulatesBucketsAndSwitches(t *testing.T) {
|
|||||||
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
||||||
|
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn) // seeds at t=1000 with code/antidrift
|
||||||
|
|
||||||
clk.advance(10 * time.Second)
|
clk.advance(10 * time.Second)
|
||||||
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
||||||
@@ -181,7 +182,7 @@ func TestUnavailableAccruesToReservedBucket(t *testing.T) {
|
|||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
c.SetClock(clk.fn())
|
c.SetClock(clk.fn())
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn) // seed: empty unavailable latestWindow
|
||||||
// latestWindow was zero-value (unavailable) at seed time.
|
// latestWindow was zero-value (unavailable) at seed time.
|
||||||
clk.advance(5 * time.Second)
|
clk.advance(5 * time.Second)
|
||||||
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
||||||
@@ -198,7 +199,7 @@ func TestCrashReplayRebuildsStats(t *testing.T) {
|
|||||||
first.SetClock(clk.fn())
|
first.SetClock(clk.fn())
|
||||||
first.RecordWindow(snap("code", "antidrift"))
|
first.RecordWindow(snap("code", "antidrift"))
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
|
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
clk.advance(15 * time.Second)
|
clk.advance(15 * time.Second)
|
||||||
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ func TestEndWritesAuditSummary(t *testing.T) {
|
|||||||
c.SetClock(clk.fn())
|
c.SetClock(clk.fn())
|
||||||
c.RecordWindow(snap("code", "antidrift"))
|
c.RecordWindow(snap("code", "antidrift"))
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil)
|
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
clk.advance(30 * time.Second)
|
clk.advance(30 * time.Second)
|
||||||
c.RecordWindow(snap("firefox", "docs"))
|
c.RecordWindow(snap("firefox", "docs"))
|
||||||
clk.advance(10 * time.Second)
|
clk.advance(10 * time.Second)
|
||||||
@@ -270,7 +271,7 @@ func (f *fakeCoach) grounding() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
||||||
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
|
func waitCoachStatus(t *testing.T, c *Mode, want string) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -373,7 +374,7 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
@@ -385,10 +386,33 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlanningStateExposesCurrentWindowClass(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
st := c.State()
|
||||||
|
if st.Evidence == nil || st.Evidence.Current.Class != "Brave-browser" {
|
||||||
|
t.Fatalf("planning should expose the live window class so the user can pick a matching token, got %+v", st.Evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddOnChangeFansOutToAllListeners(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
var a, b int
|
||||||
|
c.SetOnChange(func() { a++ })
|
||||||
|
c.AddOnChange(func() { b++ })
|
||||||
|
c.notify()
|
||||||
|
if a != 1 || b != 1 {
|
||||||
|
t.Fatalf("every registered listener should fire: a=%d b=%d", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
|
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
||||||
if err := c.OnTask(); err != nil {
|
if err := c.OnTask(); err != nil {
|
||||||
t.Fatalf("ontask: %v", err)
|
t.Fatalf("ontask: %v", err)
|
||||||
@@ -418,7 +442,7 @@ func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title str
|
|||||||
return f.verdict, f.err
|
return f.verdict, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitDriftStatus(t *testing.T, c *Controller, want string) State {
|
func waitDriftStatus(t *testing.T, c *Mode, want string) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -432,12 +456,17 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State {
|
|||||||
return State{}
|
return State{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startActive(t *testing.T, c *Controller, allowed []string) {
|
func startActive(t *testing.T, c *Mode, allowed []string) {
|
||||||
|
t.Helper()
|
||||||
|
startActiveLevel(t, c, allowed, domain.EnforcementWarn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startActiveLevel(t *testing.T, c *Mode, allowed []string, level domain.EnforcementLevel) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("planning: %v", err)
|
t.Fatalf("planning: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil {
|
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed, level); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +501,54 @@ func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
|
// titleJudge returns a verdict chosen by which substring the title contains,
|
||||||
|
// so a test can model an app (e.g. a browser) hosting both on- and off-task
|
||||||
|
// windows under one window class.
|
||||||
|
type titleJudge struct {
|
||||||
|
byTitle map[string]ai.Verdict
|
||||||
|
calls int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *titleJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||||
|
atomic.AddInt32(&j.calls, 1)
|
||||||
|
for sub, v := range j.byTitle {
|
||||||
|
if strings.Contains(title, sub) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ai.Verdict{OnTask: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brief off-task window must not poison every later same-class window. Teams
|
||||||
|
// and a Consume reading page are both class "Brave-browser"; caching the judge
|
||||||
|
// verdict by class alone made the on-task page inherit Teams's drift verdict
|
||||||
|
// (and never re-judge), latching drift for the rest of the session.
|
||||||
|
func TestVerdictCacheKeysOnTitleNotClassAlone(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
now := time.Now()
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
fj := &titleJudge{byTitle: map[string]ai.Verdict{
|
||||||
|
"Teams": {OnTask: false, Reason: "Teams is off-task"},
|
||||||
|
"Consume": {OnTask: true},
|
||||||
|
}}
|
||||||
|
c.SetDriftJudge(fj)
|
||||||
|
startActive(t, c, []string{"code"}) // Brave-browser never matches locally
|
||||||
|
|
||||||
|
c.RecordWindow(obs("Brave-browser", "Microsoft Teams - Brave"))
|
||||||
|
waitDriftStatus(t, c, "drifting")
|
||||||
|
|
||||||
|
now = now.Add(2 * driftDebounce) // clear debounce so a re-judge is allowed
|
||||||
|
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
|
||||||
|
st := waitDriftStatus(t, c, "ontask")
|
||||||
|
if st.Drift.Reason != "" {
|
||||||
|
t.Fatalf("on-task window must not carry the Teams reason: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&fj.calls); got != 2 {
|
||||||
|
t.Fatalf("different title under same class should re-judge, calls=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerWindowCacheAvoidsSecondJudge(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
c.SetClock(func() time.Time { return now })
|
c.SetClock(func() time.Time { return now })
|
||||||
@@ -482,12 +558,13 @@ func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
|
|||||||
c.RecordWindow(obs("firefox", "YouTube"))
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
waitDriftStatus(t, c, "drifting")
|
waitDriftStatus(t, c, "drifting")
|
||||||
// Advance past the debounce window so a second judge call would be allowed:
|
// Advance past the debounce window so a second judge call would be allowed:
|
||||||
// this isolates the per-class cache as the only reason the judge is skipped.
|
// this isolates the per-window cache as the only reason the judge is skipped.
|
||||||
now = now.Add(2 * driftDebounce)
|
now = now.Add(2 * driftDebounce)
|
||||||
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
|
c.RecordWindow(obs("Code", "main.go")) // switch away (local on-task match)
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube")) // same window, should hit cache
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
||||||
t.Fatalf("cached class should not re-judge, calls=%d", got)
|
t.Fatalf("cached window should not re-judge, calls=%d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +617,7 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}); err != nil {
|
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
@@ -548,17 +625,102 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
|||||||
t.Fatalf("reload: %v", err)
|
t.Fatalf("reload: %v", err)
|
||||||
}
|
}
|
||||||
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map
|
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedWindows map
|
||||||
waitDriftStatus(t, second, "drifting")
|
waitDriftStatus(t, second, "drifting")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeGuard struct{ calls int32 }
|
||||||
|
|
||||||
|
func (g *fakeGuard) MinimizeActive(context.Context) error {
|
||||||
|
atomic.AddInt32(&g.calls, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitGuardCalls(t *testing.T, g *fakeGuard, min int32) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if atomic.LoadInt32(&g.calls) >= min {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("guard minimize calls never reached %d (got %d)", min, atomic.LoadInt32(&g.calls))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriftMinimizesAtBlockViaBothPaths(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
g := &fakeGuard{}
|
||||||
|
c.SetGuard(g)
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||||
|
|
||||||
|
// Async path: first off-task observation launches the judge; on confirmation
|
||||||
|
// the guard minimizes once.
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := waitDriftStatus(t, c, "drifting")
|
||||||
|
if !st.Drift.Enforced {
|
||||||
|
t.Fatalf("Enforced should be true while drifting at block: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
waitGuardCalls(t, g, 1)
|
||||||
|
|
||||||
|
// Synchronous cached path: same class again hits the per-class cache, sets
|
||||||
|
// drifting under the lock, and minimizes again without a new judgment.
|
||||||
|
c.RecordWindow(obs("firefox", "Reddit"))
|
||||||
|
waitGuardCalls(t, g, 2)
|
||||||
|
if got := atomic.LoadInt32(&g.calls); got < 2 {
|
||||||
|
t.Fatalf("cached-drift path did not minimize: %d calls", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarnLevelNeverMinimizes(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
g := &fakeGuard{}
|
||||||
|
c.SetGuard(g)
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementWarn)
|
||||||
|
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := waitDriftStatus(t, c, "drifting")
|
||||||
|
if st.Drift.Enforced {
|
||||||
|
t.Fatalf("Enforced must be false at warn: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond) // allow any stray enforcement goroutine to run
|
||||||
|
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||||
|
t.Fatalf("warn level must not minimize, got %d calls", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnTaskNeverMinimizes(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}})
|
||||||
|
g := &fakeGuard{}
|
||||||
|
c.SetGuard(g)
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||||
|
|
||||||
|
c.RecordWindow(obs("code", "main.go")) // local on-task match
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||||
|
t.Fatalf("on-task must not minimize, got %d calls", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWithoutGuardDoesNotPanic(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
// No guard wired.
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
waitDriftStatus(t, c, "drifting") // must reach drifting without panicking
|
||||||
|
}
|
||||||
|
|
||||||
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
||||||
_ = c.RequestCoach("x")
|
_ = c.RequestCoach("x")
|
||||||
waitCoachStatus(t, c, "ready")
|
waitCoachStatus(t, c, "ready")
|
||||||
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
|
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
if c.State().Coach != nil {
|
if c.State().Coach != nil {
|
||||||
@@ -590,7 +752,7 @@ func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, er
|
|||||||
return f(ctx, c, t)
|
return f(ctx, c, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitNudge(t *testing.T, c *Controller, want string) {
|
func waitNudge(t *testing.T, c *Mode, want string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -623,8 +785,8 @@ func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
|
|||||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
startActive(t, c, []string{"code"})
|
startActive(t, c, []string{"code"})
|
||||||
c.RecordWindow(obs("firefox", "YouTube"))
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
c.RecordWindow(obs("firefox", "Reddit"))
|
|
||||||
waitDriftStatus(t, c, "drifting")
|
waitDriftStatus(t, c, "drifting")
|
||||||
|
c.RecordWindow(obs("firefox", "Reddit")) // second off-task title builds nudge history
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
||||||
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
||||||
@@ -801,8 +963,10 @@ func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
|||||||
return f.list, f.err
|
return f.list, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
|
||||||
|
|
||||||
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
|
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
|
||||||
func waitTasksStatus(t *testing.T, c *Controller, want string) State {
|
func waitTasksStatus(t *testing.T, c *Mode, want string) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -858,6 +1022,83 @@ func TestTasksViewAbsentOutsidePlanning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning exercises the
|
||||||
|
// "left planning" arm of the discard guard in startKnowledgeFetchLocked:
|
||||||
|
// a slow knowledge load that returns after the user has left planning must
|
||||||
|
// not clobber the fresh state produced by a later planning entry.
|
||||||
|
//
|
||||||
|
// Sequence:
|
||||||
|
// 1. Enter planning with a gated source (load in flight, blocked on the gate).
|
||||||
|
// 2. StartManualCommitment moves Planning -> Active, leaving planning.
|
||||||
|
// 3. Complete + End the session to return to Locked.
|
||||||
|
// 4. Install a second (ungated) source and re-enter planning.
|
||||||
|
// 5. Wait for the fresh load to reach "ready" with the new source's profile.
|
||||||
|
// 6. Release the gate — the stale goroutine from step 1 now runs and must be
|
||||||
|
// discarded (knowledgeGen has moved on / runtimeState differs at commit).
|
||||||
|
// 7. Assert, through the now-visible Planning-state projection, that the stale
|
||||||
|
// result did not clobber the fresh knowledge view. The "/stale" vs "/fresh"
|
||||||
|
// Path distinguishes the two (both texts are 5 chars, so Chars cannot).
|
||||||
|
func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
|
||||||
|
// Step 1: gated source whose result ("/stale") must never surface.
|
||||||
|
staleGate := make(chan struct{})
|
||||||
|
stale := &fakeSource{
|
||||||
|
profile: knowledge.Profile{Text: "STALE", Path: "/stale"},
|
||||||
|
gate: staleGate,
|
||||||
|
}
|
||||||
|
c.SetKnowledge(stale)
|
||||||
|
if err := c.EnterPlanning(); err != nil { // launches the gated load (gen 1)
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: leave planning via a commitment — the stale load is still blocked.
|
||||||
|
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
|
||||||
|
t.Fatalf("start commitment: %v", err)
|
||||||
|
}
|
||||||
|
if c.State().RuntimeState != domain.RuntimeActive {
|
||||||
|
t.Fatalf("expected Active after StartManualCommitment")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: end the session to return to Locked.
|
||||||
|
if err := c.Complete(); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: fresh, ungated source whose result ("/fresh") is the expected state.
|
||||||
|
fresh := &fakeSource{profile: knowledge.Profile{Text: "FRESH", Path: "/fresh"}}
|
||||||
|
c.SetKnowledge(fresh)
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("second enter planning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: wait for the fresh load to complete and be visible.
|
||||||
|
st := waitKnowledgeStatus(t, c, "ready")
|
||||||
|
if st.Knowledge.Path != "/fresh" || st.Knowledge.Chars != len("FRESH") {
|
||||||
|
t.Fatalf("expected FRESH knowledge after second planning entry, got %+v", st.Knowledge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: release the stale goroutine — it must detect that its generation is
|
||||||
|
// stale and discard rather than overwrite the fresh view.
|
||||||
|
close(staleGate)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Step 7: the fresh view must still be intact; "/stale" must not have landed.
|
||||||
|
st = c.State()
|
||||||
|
if st.Knowledge == nil {
|
||||||
|
t.Fatalf("knowledge view should still be present in planning")
|
||||||
|
}
|
||||||
|
if st.Knowledge.Status != "ready" {
|
||||||
|
t.Fatalf("knowledge status should still be ready, got %q", st.Knowledge.Status)
|
||||||
|
}
|
||||||
|
if st.Knowledge.Path != "/fresh" || st.Knowledge.Chars != len("FRESH") {
|
||||||
|
t.Fatalf("stale knowledge fetch clobbered the fresh view: %+v", st.Knowledge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestStaleTasksFetchDiscardedAfterLeavingPlanning exercises the
|
// TestStaleTasksFetchDiscardedAfterLeavingPlanning exercises the
|
||||||
// "left planning" arm of the discard guard in startTasksFetchLocked.
|
// "left planning" arm of the discard guard in startTasksFetchLocked.
|
||||||
//
|
//
|
||||||
@@ -887,7 +1128,7 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
|||||||
waitTasksStatus(t, c, "pending")
|
waitTasksStatus(t, c, "pending")
|
||||||
|
|
||||||
// Step 2: leave planning via a commitment — the stale fetch is still blocked.
|
// Step 2: leave planning via a commitment — the stale fetch is still blocked.
|
||||||
if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil); err != nil {
|
if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start commitment: %v", err)
|
t.Fatalf("start commitment: %v", err)
|
||||||
}
|
}
|
||||||
if c.State().RuntimeState != domain.RuntimeActive {
|
if c.State().RuntimeState != domain.RuntimeActive {
|
||||||
@@ -936,14 +1177,18 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
|||||||
type fakeSource struct {
|
type fakeSource struct {
|
||||||
profile knowledge.Profile
|
profile knowledge.Profile
|
||||||
err error
|
err error
|
||||||
|
gate chan struct{} // if non-nil, Load blocks until it receives
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
return f.profile, f.err
|
return f.profile, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s.
|
// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s.
|
||||||
func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State {
|
func waitKnowledgeStatus(t *testing.T, c *Mode, want string) State {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -1029,3 +1274,311 @@ func TestCoachReceivesCachedGrounding(t *testing.T) {
|
|||||||
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
|
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeReviewer struct {
|
||||||
|
refl ai.Reflection
|
||||||
|
err error
|
||||||
|
gate chan struct{} // if non-nil, Review blocks until it receives
|
||||||
|
calls int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) {
|
||||||
|
atomic.AddInt32(&f.calls, 1)
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
|
return f.refl, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitReflectionStatus polls until the reflection view reaches want, or fails after 2s.
|
||||||
|
func waitReflectionStatus(t *testing.T, c *Mode, want string) State {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st := c.State()
|
||||||
|
if st.Reflection != nil && st.Reflection.Status == want {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("reflection status never reached %q (last: %+v)", want, c.State().Reflection)
|
||||||
|
return State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveToReview(t *testing.T, c *Mode) {
|
||||||
|
t.Helper()
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Complete(); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectionFetchedOnReview(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
rev := &fakeReviewer{refl: ai.Reflection{Recap: "held focus", CarryForward: "start in the editor"}}
|
||||||
|
c.SetReviewer(rev)
|
||||||
|
driveToReview(t, c)
|
||||||
|
st := waitReflectionStatus(t, c, "ready")
|
||||||
|
if st.Reflection.Recap != "held focus" {
|
||||||
|
t.Fatalf("recap not projected on Review: %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&rev.calls); n != 1 {
|
||||||
|
t.Fatalf("reviewer should be called exactly once per session, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoReviewerYieldsIdleReflection(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
driveToReview(t, c)
|
||||||
|
st := c.State()
|
||||||
|
if st.Reflection == nil || st.Reflection.Status != "idle" {
|
||||||
|
t.Fatalf("nil reviewer should yield idle reflection, got %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("End must still work with no reviewer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCarryForwardGroundsNextCoach(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "begin with the hardest test"}})
|
||||||
|
fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}}
|
||||||
|
c.SetCoach(fc)
|
||||||
|
driveToReview(t, c)
|
||||||
|
waitReflectionStatus(t, c, "ready")
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.RequestCoach("ship it"); err != nil {
|
||||||
|
t.Fatalf("request coach: %v", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && fc.grounding() == "" {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if g := fc.grounding(); !strings.Contains(g, "begin with the hardest test") {
|
||||||
|
t.Fatalf("carry-forward not threaded into coach grounding: %q", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectionStaleResultDiscarded(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
slow := &fakeReviewer{refl: ai.Reflection{Recap: "OLD", CarryForward: "old"}, gate: make(chan struct{})}
|
||||||
|
c.SetReviewer(slow)
|
||||||
|
driveToReview(t, c) // gen1 pending, goroutine blocks on gate
|
||||||
|
waitReflectionStatus(t, c, "pending")
|
||||||
|
|
||||||
|
_ = c.End() // Review -> Locked; gen1 still blocked
|
||||||
|
fast := &fakeReviewer{refl: ai.Reflection{Recap: "NEW", CarryForward: "new"}}
|
||||||
|
c.SetReviewer(fast)
|
||||||
|
driveToReview(t, c) // gen2 -> ready NEW
|
||||||
|
st := waitReflectionStatus(t, c, "ready")
|
||||||
|
if st.Reflection.Recap != "NEW" {
|
||||||
|
t.Fatalf("expected NEW, got %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(slow.gate) // release gen1; it must be discarded
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := c.State().Reflection.Recap; got != "NEW" {
|
||||||
|
t.Fatalf("stale gen1 overwrote reflection: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCarryForwardSurvivesRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
first.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "lead with tests"}})
|
||||||
|
if err := first.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := first.Complete(); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
waitReflectionStatus(t, first, "ready")
|
||||||
|
if err := first.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
if err := second.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("planning: %v", err)
|
||||||
|
}
|
||||||
|
st := second.State()
|
||||||
|
if st.Reflection == nil || st.Reflection.CarryForward != "lead with tests" {
|
||||||
|
t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforcementLevelPersists(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first.SetClock(func() time.Time { return time.Unix(1000, 0) })
|
||||||
|
if err := first.EnterPlanning(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementBlock); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := second.EnforcementLevelForTest(); got != domain.EnforcementBlock {
|
||||||
|
t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreditLockedSplitsByDriftStatus(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.stats = &EvidenceStats{
|
||||||
|
Buckets: map[bucketKey]time.Duration{},
|
||||||
|
OnTask: map[bucketKey]time.Duration{},
|
||||||
|
OffTask: map[bucketKey]time.Duration{},
|
||||||
|
}
|
||||||
|
k := bucketKey{Class: "code", Title: "main.go"}
|
||||||
|
|
||||||
|
c.driftStatus = driftOnTask
|
||||||
|
c.creditLocked(k, 10*time.Second)
|
||||||
|
c.driftStatus = driftDrifting
|
||||||
|
c.creditLocked(k, 5*time.Second)
|
||||||
|
c.driftStatus = driftIdle
|
||||||
|
c.creditLocked(k, 3*time.Second)
|
||||||
|
c.driftStatus = driftPending
|
||||||
|
c.creditLocked(k, 2*time.Second)
|
||||||
|
|
||||||
|
if got := c.stats.OnTask[k]; got != 10*time.Second {
|
||||||
|
t.Errorf("OnTask = %v, want 10s", got)
|
||||||
|
}
|
||||||
|
if got := c.stats.OffTask[k]; got != 5*time.Second {
|
||||||
|
t.Errorf("OffTask = %v, want 5s", got)
|
||||||
|
}
|
||||||
|
if got := c.stats.unclassified; got != 5*time.Second { // 3s idle + 2s pending
|
||||||
|
t.Errorf("unclassified = %v, want 5s", got)
|
||||||
|
}
|
||||||
|
if got := c.stats.Buckets[k]; got != 20*time.Second { // total of all four
|
||||||
|
t.Errorf("Buckets total = %v, want 20s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectionBlockShowsOnOffSplit(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
startActive(t, c, nil) // sets commitment ("write report" / "report drafted") and stats
|
||||||
|
|
||||||
|
c.stats.SwitchCount = 2
|
||||||
|
c.stats.OnTask = map[bucketKey]time.Duration{
|
||||||
|
{Class: "code", Title: "main.go"}: 20 * time.Minute,
|
||||||
|
}
|
||||||
|
c.stats.OffTask = map[bucketKey]time.Duration{
|
||||||
|
{Class: "firefox", Title: "YouTube"}: 8 * time.Minute,
|
||||||
|
{Class: "firefox", Title: "Reddit"}: 4 * time.Minute,
|
||||||
|
}
|
||||||
|
c.stats.unclassified = 5 * time.Minute
|
||||||
|
c.outcomePending = "completed"
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
got := c.buildReflectionFinishedLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
want := "Next action: write report\n" +
|
||||||
|
"Success condition: report drafted\n" +
|
||||||
|
"Outcome: completed\n" +
|
||||||
|
"On-task 20m / Off-task 12m / Unclassified 5m\n" +
|
||||||
|
"Context switches: 2\n" +
|
||||||
|
"On-task:\n" +
|
||||||
|
"- code · main.go: 20m\n" +
|
||||||
|
"Off-task:\n" +
|
||||||
|
"- firefox · YouTube: 8m\n" +
|
||||||
|
"- firefox · Reddit: 4m"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("reflection block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReflectionBlockOmitsEmptyOffTaskList(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
startActive(t, c, nil)
|
||||||
|
|
||||||
|
c.stats.OnTask = map[bucketKey]time.Duration{
|
||||||
|
{Class: "code", Title: "main.go"}: 20 * time.Minute,
|
||||||
|
}
|
||||||
|
c.stats.OffTask = map[bucketKey]time.Duration{} // none
|
||||||
|
c.stats.unclassified = 0
|
||||||
|
c.outcomePending = "completed"
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
got := c.buildReflectionFinishedLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if strings.Contains(got, "Off-task:") {
|
||||||
|
t.Errorf("fully on-task session must omit the Off-task list, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "On-task 20m / Off-task 0m / Unclassified 0m") {
|
||||||
|
t.Errorf("totals line missing or wrong, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordWindowCreditsSplitFaithfully(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
|
c.SetClock(clk.fn())
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}})
|
||||||
|
|
||||||
|
c.RecordWindow(snap("code", "main.go")) // latest window before start
|
||||||
|
startActive(t, c, []string{"code"}) // seeds code/main.go at t=1000, driftStatus idle
|
||||||
|
|
||||||
|
clk.advance(5 * time.Minute)
|
||||||
|
c.RecordWindow(snap("code", "main.go")) // credits 5m to code/main.go while idle -> unclassified; now on-task (local match)
|
||||||
|
clk.advance(10 * time.Minute)
|
||||||
|
c.RecordWindow(snap("code", "main.go")) // credits 10m on-task
|
||||||
|
clk.advance(10 * time.Minute)
|
||||||
|
c.RecordWindow(snap("firefox", "YouTube")) // credits 10m on-task (total 20m); firefox -> judge (pending)
|
||||||
|
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
|
||||||
|
|
||||||
|
clk.advance(8 * time.Minute)
|
||||||
|
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task
|
||||||
|
waitDriftStatus(t, c, "drifting") // Reddit is a distinct window: re-judged off-task before its segment is credited
|
||||||
|
clk.advance(4 * time.Minute)
|
||||||
|
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
got := c.buildReflectionFinishedLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
want := "Next action: write report\n" +
|
||||||
|
"Success condition: report drafted\n" +
|
||||||
|
"Outcome: completed\n" +
|
||||||
|
"On-task 20m / Off-task 12m / Unclassified 5m\n" +
|
||||||
|
"Context switches: 2\n" +
|
||||||
|
"On-task:\n" +
|
||||||
|
"- code · main.go: 20m\n" +
|
||||||
|
"Off-task:\n" +
|
||||||
|
"- firefox · YouTube: 8m\n" +
|
||||||
|
"- firefox · Reddit: 4m"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("faithful split mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package statusfile mirrors the controller's runtime status into a single-line
|
// Package statusfile mirrors the harness's runtime status into a single-line
|
||||||
// file (~/.antidrift_status) so an external consumer — a window-manager status
|
// file (~/.keel_status) so an external consumer — a window-manager status
|
||||||
// bar — can display it with a plain file read.
|
// bar — can display it with a plain file read.
|
||||||
package statusfile
|
package statusfile
|
||||||
|
|
||||||
@@ -9,20 +9,22 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode"
|
||||||
"antidrift/internal/session"
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// statusFileName is the file written under the user's home directory.
|
// statusFileName is the file written under the user's home directory.
|
||||||
const statusFileName = ".antidrift_status"
|
const statusFileName = ".keel_status"
|
||||||
|
|
||||||
// interval is how often the writer re-renders. Minute granularity is enough for
|
// interval is how often the writer re-renders. Minute granularity is enough for
|
||||||
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
|
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
|
||||||
const interval = time.Minute
|
const interval = time.Minute
|
||||||
|
|
||||||
// DefaultPath resolves ~/.antidrift_status.
|
// DefaultPath resolves ~/.keel_status.
|
||||||
func DefaultPath() (string, error) {
|
func DefaultPath() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,10 +33,62 @@ func DefaultPath() (string, error) {
|
|||||||
return filepath.Join(home, statusFileName), nil
|
return filepath.Join(home, statusFileName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render produces the single status line for the given state. It is empty for
|
// Render produces the single status line for the harness envelope, dispatching
|
||||||
// Locked/idle so a bar module can hide itself. Drift status strings ("drifting")
|
// on the active mode. An idle harness ("") renders the ambient coaching line as
|
||||||
|
// "⚠ <line>" when one is present, else "idle"; an unknown kind renders a generic
|
||||||
|
// fallback so a future mode degrades gracefully.
|
||||||
|
func Render(env mode.Envelope, ambientLine string, now time.Time) string {
|
||||||
|
switch env.ActiveMode {
|
||||||
|
case "":
|
||||||
|
if line := strings.TrimSpace(ambientLine); line != "" {
|
||||||
|
return "⚠ " + line
|
||||||
|
}
|
||||||
|
return "idle"
|
||||||
|
case "focus":
|
||||||
|
st, ok := env.Mode.(focus.State)
|
||||||
|
if !ok {
|
||||||
|
// Defensive: the envelope claims focus but carries an unexpected
|
||||||
|
// payload. Render a safe fallback instead of panicking on the assert.
|
||||||
|
return "focus"
|
||||||
|
}
|
||||||
|
return renderFocus(st, now)
|
||||||
|
case "offscreen":
|
||||||
|
return renderOffscreen(env.Mode)
|
||||||
|
default:
|
||||||
|
return env.ActiveMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOffscreen produces the off-screen status line from the mode's untyped
|
||||||
|
// View payload (a map[string]any read from the live in-process envelope). It
|
||||||
|
// shows the proposed next action when one is present, a warning glyph on error,
|
||||||
|
// and a "thinking…" placeholder otherwise. Any payload mismatch degrades to the
|
||||||
|
// placeholder rather than panicking.
|
||||||
|
func renderOffscreen(m any) string {
|
||||||
|
const thinking = "off-screen: thinking…"
|
||||||
|
view, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return thinking
|
||||||
|
}
|
||||||
|
status, _ := view["status"].(string)
|
||||||
|
switch status {
|
||||||
|
case "proposed":
|
||||||
|
na, _ := view["next_action"].(string)
|
||||||
|
if na = strings.TrimSpace(na); na != "" {
|
||||||
|
return "off-screen: " + na
|
||||||
|
}
|
||||||
|
return thinking
|
||||||
|
case "error":
|
||||||
|
return "off-screen: ⚠"
|
||||||
|
default:
|
||||||
|
return thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFocus produces the focus status line. It is empty for Locked/idle focus
|
||||||
|
// states so a bar module can hide itself. Drift status strings ("drifting")
|
||||||
// match the JSON contract the web UI already consumes.
|
// match the JSON contract the web UI already consumes.
|
||||||
func Render(st session.State, now time.Time) string {
|
func renderFocus(st focus.State, now time.Time) string {
|
||||||
switch st.RuntimeState {
|
switch st.RuntimeState {
|
||||||
case domain.RuntimeActive:
|
case domain.RuntimeActive:
|
||||||
timer := remaining(st, now)
|
timer := remaining(st, now)
|
||||||
@@ -58,7 +112,7 @@ func Render(st session.State, now time.Time) string {
|
|||||||
// remaining formats the minutes left until the commitment deadline, rounded up
|
// remaining formats the minutes left until the commitment deadline, rounded up
|
||||||
// so a partial minute still reads as a minute and the count hits 0m only at the
|
// so a partial minute still reads as a minute and the count hits 0m only at the
|
||||||
// end. It returns 0m when no deadline is known.
|
// end. It returns 0m when no deadline is known.
|
||||||
func remaining(st session.State, now time.Time) string {
|
func remaining(st focus.State, now time.Time) string {
|
||||||
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
|
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
|
||||||
return "0m"
|
return "0m"
|
||||||
}
|
}
|
||||||
@@ -70,25 +124,43 @@ func remaining(st session.State, now time.Time) string {
|
|||||||
return fmt.Sprintf("%dm", mins)
|
return fmt.Sprintf("%dm", mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writer periodically renders the controller state and writes it to a file,
|
// Writer periodically renders the harness envelope (plus the ambient line) and
|
||||||
// rewriting only when the line changes.
|
// writes it to a file, rewriting only when the line changes.
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
path string
|
path string
|
||||||
state func() session.State
|
state func() mode.Envelope
|
||||||
|
ambient func() string
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
last string
|
last string
|
||||||
wrote bool
|
wrote bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWriter builds a Writer for path, reading state via the given accessor.
|
// NewWriter builds a Writer for path, reading the harness envelope via state and
|
||||||
func NewWriter(path string, state func() session.State) *Writer {
|
// the ambient coaching line via ambient (either may be supplied; a nil ambient
|
||||||
return &Writer{path: path, state: state, now: time.Now}
|
// is treated as "" by write).
|
||||||
|
func NewWriter(path string, state func() mode.Envelope, ambient func() string) *Writer {
|
||||||
|
return &Writer{path: path, state: state, ambient: ambient, now: time.Now, wake: make(chan struct{}, 1)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run writes the status file immediately, then on every tick when the rendered
|
// Wake asks the writer to re-render now rather than at the next tick. It is the
|
||||||
// line has changed. It removes the file on ctx cancellation so a stale status
|
// hook the harness's change notifications fire through, so drift transitions
|
||||||
// does not linger after shutdown.
|
// reach the status bar promptly instead of lagging the web UI by up to a tick.
|
||||||
|
// The signal is coalesced (buffered, size 1): a burst of changes collapses into
|
||||||
|
// a single re-render, and the actual write still happens on the Run goroutine,
|
||||||
|
// so concurrent callers never race on the file.
|
||||||
|
func (w *Writer) Wake() {
|
||||||
|
select {
|
||||||
|
case w.wake <- struct{}{}:
|
||||||
|
default: // a re-render is already pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run writes the status file immediately, then re-renders on each wake or tick
|
||||||
|
// when the rendered line has changed. The tick still advances the minute
|
||||||
|
// countdown when nothing else changes. It removes the file on ctx cancellation
|
||||||
|
// so a stale status does not linger after shutdown.
|
||||||
func (w *Writer) Run(ctx context.Context) {
|
func (w *Writer) Run(ctx context.Context) {
|
||||||
t := time.NewTicker(interval)
|
t := time.NewTicker(interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -98,6 +170,8 @@ func (w *Writer) Run(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = os.Remove(w.path)
|
_ = os.Remove(w.path)
|
||||||
return
|
return
|
||||||
|
case <-w.wake:
|
||||||
|
w.write()
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
w.write()
|
w.write()
|
||||||
}
|
}
|
||||||
@@ -105,14 +179,18 @@ func (w *Writer) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Writer) write() {
|
func (w *Writer) write() {
|
||||||
line := Render(w.state(), w.now())
|
line := ""
|
||||||
if w.wrote && line == w.last {
|
if w.ambient != nil {
|
||||||
|
line = w.ambient()
|
||||||
|
}
|
||||||
|
rendered := Render(w.state(), line, w.now())
|
||||||
|
if w.wrote && rendered == w.last {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(w.path, []byte(line), 0o644); err != nil {
|
if err := os.WriteFile(w.path, []byte(rendered), 0o644); err != nil {
|
||||||
log.Printf("statusfile: write %s: %v", w.path, err)
|
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.last = line
|
w.last = rendered
|
||||||
w.wrote = true
|
w.wrote = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,32 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode"
|
||||||
"antidrift/internal/session"
|
"keel/internal/mode/focus"
|
||||||
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func activeState(deadline int64, driftStatus, nudge string) session.State {
|
// focusEnv wraps a focus.State in the harness envelope the writer now consumes.
|
||||||
st := session.State{
|
func focusEnv(st focus.State) mode.Envelope {
|
||||||
|
return mode.Envelope{ActiveMode: "focus", Mode: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusState builds a focus.State with the given runtime state.
|
||||||
|
func focusState(rt domain.RuntimeState) focus.State {
|
||||||
|
return focus.State{RuntimeState: rt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeState(deadline int64, driftStatus, nudge string) focus.State {
|
||||||
|
st := focus.State{
|
||||||
RuntimeState: domain.RuntimeActive,
|
RuntimeState: domain.RuntimeActive,
|
||||||
Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline},
|
Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline},
|
||||||
}
|
}
|
||||||
if driftStatus != "" || nudge != "" {
|
if driftStatus != "" || nudge != "" {
|
||||||
st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge}
|
st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -28,25 +40,33 @@ func TestRender(t *testing.T) {
|
|||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
st session.State
|
env mode.Envelope
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""},
|
{"idle", mode.Envelope{}, "idle"},
|
||||||
{"empty runtime", session.State{}, ""},
|
{"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""},
|
||||||
{"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"},
|
{"empty focus runtime", focusEnv(focus.State{}), ""},
|
||||||
{"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"},
|
{"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"},
|
||||||
{"active ontask", activeState(in24m, "ontask", ""), "● 24m"},
|
{"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"},
|
||||||
{"active no drift view", activeState(in24m, "", ""), "● 24m"},
|
{"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"},
|
||||||
{"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"},
|
{"active no drift view", focusEnv(activeState(in24m, "", "")), "● 24m"},
|
||||||
{"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"},
|
{"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"},
|
||||||
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"},
|
{"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), "● 24m ·?"},
|
||||||
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"},
|
{"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"},
|
||||||
{"active past deadline", activeState(now.Add(-5 * time.Minute).Unix(), "ontask", ""), "● 0m"},
|
{"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"},
|
||||||
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"},
|
{"active past deadline", focusEnv(activeState(now.Add(-5*time.Minute).Unix(), "ontask", "")), "● 0m"},
|
||||||
|
{"active partial minute rounds up", focusEnv(activeState(now.Add(10*time.Second).Unix(), "ontask", "")), "● 1m"},
|
||||||
|
{"unknown kind falls back to kind name", mode.Envelope{ActiveMode: "house"}, "house"},
|
||||||
|
{"focus with wrong payload falls back", mode.Envelope{ActiveMode: "focus", Mode: "bogus"}, "focus"},
|
||||||
|
{"offscreen proposed", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "call mum"}}, "off-screen: call mum"},
|
||||||
|
{"offscreen pending", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "pending"}}, "off-screen: thinking…"},
|
||||||
|
{"offscreen error", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "error", "error": "boom"}}, "off-screen: ⚠"},
|
||||||
|
{"offscreen proposed no action", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed"}}, "off-screen: thinking…"},
|
||||||
|
{"offscreen wrong payload", mode.Envelope{ActiveMode: "offscreen", Mode: "bogus"}, "off-screen: thinking…"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if got := Render(tc.st, now); got != tc.want {
|
if got := Render(tc.env, "", now); got != tc.want {
|
||||||
t.Errorf("Render() = %q, want %q", got, tc.want)
|
t.Errorf("Render() = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,8 +76,8 @@ func TestRender(t *testing.T) {
|
|||||||
func TestWriterWritesAndDedups(t *testing.T) {
|
func TestWriterWritesAndDedups(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
now := time.Unix(1_000_000, 0)
|
now := time.Unix(1_000_000, 0)
|
||||||
state := session.State{RuntimeState: domain.RuntimePlanning}
|
env := focusEnv(focusState(domain.RuntimePlanning))
|
||||||
w := NewWriter(path, func() session.State { return state })
|
w := NewWriter(path, func() mode.Envelope { return env }, nil)
|
||||||
w.now = func() time.Time { return now }
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
w.write()
|
w.write()
|
||||||
@@ -76,7 +96,7 @@ func TestWriterWritesAndDedups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Changed state: rewrites.
|
// Changed state: rewrites.
|
||||||
state = session.State{RuntimeState: domain.RuntimeLocked}
|
env = focusEnv(focusState(domain.RuntimeLocked))
|
||||||
w.write()
|
w.write()
|
||||||
if got := readFile(t, path); got != "" {
|
if got := readFile(t, path); got != "" {
|
||||||
t.Errorf("changed write = %q, want empty", got)
|
t.Errorf("changed write = %q, want empty", got)
|
||||||
@@ -85,9 +105,9 @@ func TestWriterWritesAndDedups(t *testing.T) {
|
|||||||
|
|
||||||
func TestWriterRemovesFileOnCancel(t *testing.T) {
|
func TestWriterRemovesFileOnCancel(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "status")
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
w := NewWriter(path, func() session.State {
|
w := NewWriter(path, func() mode.Envelope {
|
||||||
return session.State{RuntimeState: domain.RuntimePlanning}
|
return focusEnv(focusState(domain.RuntimePlanning))
|
||||||
})
|
}, nil)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() { w.Run(ctx); close(done) }()
|
go func() { w.Run(ctx); close(done) }()
|
||||||
@@ -101,6 +121,59 @@ func TestWriterRemovesFileOnCancel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wake makes the writer re-render on a state change instead of waiting for the
|
||||||
|
// coarse minute tick, so the status bar tracks drift transitions promptly
|
||||||
|
// instead of lagging the web UI by up to a minute.
|
||||||
|
func TestWriterWakeRendersPromptly(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
in10m := now.Add(10 * time.Minute).Unix()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
st := activeState(in10m, "ontask", "")
|
||||||
|
w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) }, nil)
|
||||||
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go w.Run(ctx)
|
||||||
|
|
||||||
|
waitFor(t, func() bool { return fileEquals(path, "● 10m") })
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
st = activeState(in10m, "drifting", "")
|
||||||
|
mu.Unlock()
|
||||||
|
w.Wake()
|
||||||
|
|
||||||
|
waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderIdleShowsAmbientLine(t *testing.T) {
|
||||||
|
got := Render(mode.Envelope{ActiveMode: ""}, "deep in YouTube, no goal", time.Unix(0, 0))
|
||||||
|
if got != "⚠ deep in YouTube, no goal" {
|
||||||
|
t.Fatalf("idle+ambient render = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderIdleNoAmbientLineIsIdle(t *testing.T) {
|
||||||
|
if got := Render(mode.Envelope{ActiveMode: ""}, "", time.Unix(0, 0)); got != "idle" {
|
||||||
|
t.Fatalf("idle+no-ambient render = %q, want idle", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderActiveModeIgnoresAmbientLine(t *testing.T) {
|
||||||
|
// A non-idle envelope renders its own mode, never the ambient line.
|
||||||
|
got := Render(mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "walk"}}, "ambient ignored", time.Unix(0, 0))
|
||||||
|
if got == "ambient ignored" || got == "⚠ ambient ignored" {
|
||||||
|
t.Fatalf("active mode must not render the ambient line, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileEquals(path, want string) bool {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
return err == nil && string(b) == want
|
||||||
|
}
|
||||||
|
|
||||||
func readFile(t *testing.T, path string) string {
|
func readFile(t *testing.T, path string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"antidrift/internal/domain"
|
"keel/internal/mode/focus/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Snapshot is the persisted current state of the daemon.
|
// Snapshot is the persisted current state of the daemon.
|
||||||
@@ -21,15 +21,21 @@ type Snapshot struct {
|
|||||||
OutcomePending string `json:"outcome_pending,omitempty"`
|
OutcomePending string `json:"outcome_pending,omitempty"`
|
||||||
|
|
||||||
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||||
|
|
||||||
|
ReflectionStatus string `json:"reflection_status,omitempty"`
|
||||||
|
ReflectionRecap string `json:"reflection_recap,omitempty"`
|
||||||
|
CarryForward string `json:"carry_forward,omitempty"`
|
||||||
|
|
||||||
|
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns ~/.antidrift/state.json.
|
// DefaultPath returns ~/.keel/state.json.
|
||||||
func DefaultPath() (string, error) {
|
func DefaultPath() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(home, ".antidrift", "state.json"), nil
|
return filepath.Join(home, ".keel", "state.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads a snapshot. A missing file yields a default Locked snapshot.
|
// Load reads a snapshot. A missing file yields a default Locked snapshot.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user