Compare commits

...

34 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:57:14 -04:00
felixm 13633ffabf Fix drift sync: per-window verdicts, forgiving class match, event-driven status bar
Three independent defects made the focus state feel flaky and let the OS
status bar disagree with the web UI:

- Status file lagged the web by up to a minute: it rendered only on a 60s
  ticker with no hook into state changes. notify() now fans out to multiple
  listeners (AddOnChange) and the writer has a coalesced Wake() so drift
  reaches the bar as promptly as the browser.

- A brief off-task visit could latch drift for the whole session. Sibling
  windows of one app (a browser's reading tab vs its chat tab) share a window
  class, but the judge verdict was cached by class alone, so one tab's verdict
  poisoned the rest and never re-judged. Cache is now keyed by class + scrubbed
  title (judgedWindows) so siblings are judged independently.

- Allowed-class matching was exact equality, so a short token ("brave") never
  matched the real WM_CLASS ("Brave-browser") and every window was routed to
  the LLM. Matching is now substring-based, and planning surfaces the live
  window class with a click-to-add chip so users pick a token that matches.

Also fix the planning checkbox alignment: the global full-width input rule was
stretching the "Enforce focus" checkbox; scope it out for checkboxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:25:30 -04:00
felixm 9012d5ddc6 Purge leftover Rust implementation
Remove the legacy/ Rust tree (Cargo manifests, .rs sources, desktop
entry), drop Rust-specific .gitignore blocks and README note, and strip
now-dangling "ported from Rust" comments from the Go sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:06:14 -04:00
91 changed files with 10689 additions and 9245 deletions
+1 -11
View File
@@ -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
+64
View File
@@ -0,0 +1,64 @@
# Keel — Agent Context
You are working in **Keel**, Felix's human-harness. Read this, then treat
`docs/keel-architecture.md` as the source of truth for anything architectural.
## What this is
The inversion of an agent harness: instead of a harness watching a model work, a
model (the brain) helps observe and steer a *human*. Felix is the observed
subject; the brain is the operator. The thing we build is **the harness** — the
framework that collects Felix's real state, hands it to a swappable brain, and
acts on the reply.
- **Brain** — swappable, rented compute: `claude` / `codex` / **Hermes** (real,
on another machine). Not the point; interchangeable behind one interface.
- **Storage** — **ActivityWatch** buckets (`keel.state`, `keel.events`). No new
DB / event store. Buckets hold *pointers*, not copies.
- **Frame** — `~/owc` (values, goals, `resources/bug-*.md` life-bugs). Reference,
never duplicate.
- **Surfaces** — a phone-friendly web UI + the WM status bar (`~/.keel_status`).
- **Effectors** — gated writes (Marvin tasks, capture, AntiDrift enforce; later
Beeminder datapoints, `~/owc` notes). Start propose-and-confirm; trend toward
autonomy. The gate is per-effector config — Felix's agency-vs-cage dial.
## What runs today
**AntiDrift**, Keel's first *mode*: a Go focus daemon (ports-and-adapters) —
`evidence.Source` (perception) · `tasks.Provider` (Marvin) · `knowledge.Source`
(the `~/owc`-derived profile) · `ai.Backend` (the brain) · `enforce.Guard`
(window-minimize). The cockpit work is **loosening its session controller** into a
general *collect → brain → act* loop where a focus-session is one mode among many.
```bash
go run ./cmd/keeld # local web UI at http://localhost:7777
go test ./...
```
## Naming / rename status
The rename is **complete**. Directory, git repo, Go module, binary, runtime, and
env are all `keel`: module `keel`, binary `keeld`, runtime `~/.keel/`,
`KEEL_*` env. If you have a live ledger under the old `~/.antidrift/` directory,
move it manually to `~/.keel/` — there is no automated migration tool.
## How to work here
- **Reuse before building.** Read the code before claiming something is missing.
AntiDrift already is the harness skeleton; widen it, don't rebuild.
- **Reference, don't duplicate.** `~/owc` and each tool's store stay source of
truth; Keel never becomes a hidden second source.
- **Ship visible proof.** Every session ends with a real artifact — reframing and
planning are not progress. Felix's named failure mode is tooling-as-avoidance;
resist it.
- **Concise coaching.** The status line is one signal / one action / one question.
Not "nice summaries" — that was explicitly rejected.
- **Local-first; mind secrets.** The operator runs as the full Unix user with no
sandbox; window titles and on-disk keys can leak. Filter at the assembler.
## Pointers
- `docs/keel-architecture.md` — authoritative architecture (components, data flow,
AW-as-storage, the open questions in §8).
- `docs/superpowers/` — AntiDrift focus-mode feature specs/plans (historical).
- `README.md` — project front door.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+39 -13
View File
@@ -1,19 +1,45 @@
# AntiDrift # Keel
A personal focus operating system: treat each work session as an explicit A **human-harness** that helps Felix hold course toward his higher goals: it
commitment (next action, success condition, timebox), and make drift visible. collects his real state from the tools he already uses, hands it to a swappable
brain (`claude` / `codex` / Hermes), and acts on the reply through gated
effectors — surfaced on a web UI and the WM status bar. The brain, the storage
(ActivityWatch), and the data sources are reused; the harness is what we build.
This is the Go reimagining. The original Rust implementation is preserved under > **Architecture:** [`docs/keel-architecture.md`](docs/keel-architecture.md) is the
`legacy/` for reference. See `docs/superpowers/specs/` for the design. > source of truth. **Agents:** read [`AGENTS.md`](AGENTS.md) first.
## Modes
Keel's controller is now a generic **harness** that hosts one *mode* at a time,
and the daemon ships two:
- **Focus (AntiDrift)** — Keel's first mode: a personal focus operating system
that treats each work session as an explicit commitment (next action, success
condition, timebox) and makes drift visible. The milestone history below is
this mode's record.
- **Off-screen** — an away-from-the-desk mode: it reads today's Marvin tasks plus
the `~/owc` goals and life-domain `bug-*.md` notes, asks the brain for one
worthwhile off-screen action, and on confirm files it as a Marvin task.
When no mode is active the web UI shows a **launcher** to start either one.
Further modes (body, review, capture) are aspirational. The milestone history
below records the focus mode's development.
> **Naming:** the rename is complete — directory, repo, Go module, binary,
> runtime, and env are all `keel` (`keeld` binary, `~/.keel/` runtime,
> `KEEL_*` env). If you have a live ledger under the old `~/.antidrift/`
> directory, move it manually to `~/.keel/`.
## Run ## Run
```bash ```bash
go run ./cmd/antidriftd go run ./cmd/keeld
``` ```
The daemon serves a local web UI at http://localhost:7777 and opens your The daemon serves a local web UI at http://localhost:7777 and opens your
browser. State is persisted to `~/.antidrift/state.json`. browser. Per-mode state is persisted under `~/.keel/modes/<kind>/` (e.g.
`~/.keel/modes/focus/state.json`).
## Test ## Test
@@ -41,8 +67,8 @@ into the coach's grounding; the reflection lines are short and cross the wire
by design, while the knowledge profile still does not. by design, while the knowledge profile still does not.
M6 (knowledge port): the planning coach now sharpens intents against who you M6 (knowledge port): the planning coach now sharpens intents against who you
actually are. A single profile file (`~/.antidrift/knowledge.md`, overridable actually are. A single profile file (`~/.keel/knowledge.md`, overridable
via `ANTIDRIFT_KNOWLEDGE_FILE`) holds your standing context — priorities, via `KEEL_KNOWLEDGE_FILE`) holds your standing context — priorities,
values, what counts as good work — and the daemon loads it asynchronously on values, what counts as good work — and the daemon loads it asynchronously on
entering planning, mirroring the tasks fetch. The cached text grounds the AI entering planning, mirroring the tasks fetch. The cached text grounds the AI
coach prompt only; the drift judge and nudge are untouched, and the profile text coach prompt only; the drift judge and nudge are untouched, and the profile text
@@ -70,12 +96,12 @@ The drift judge degrades gracefully — without it, local matching still runs.
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened" M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
into a structured commitment (next action, success condition, timebox) by an into a structured commitment (next action, success condition, timebox) by an
LLM CLI backend (claude or codex, selectable via `ANTIDRIFT_AI_BACKEND`). The LLM CLI backend (claude or codex, selectable via `KEEL_AI_BACKEND`). The
coach runs asynchronously and degrades gracefully — manual planning always coach runs asynchronously and degrades gracefully — manual planning always
works. works.
M1 (evidence & audit): active-window tracking, two-tier evidence store M1 (evidence & audit): active-window tracking, two-tier evidence store
(disposable per-session raw log + permanent hash-chained session summaries), (disposable per-session raw log + permanent hash-chained session summaries),
and live SSE updates. Live drift judgment and the ambient nudge arrive in later and live SSE updates. Live drift judgment and the ambient nudge arrived in later
milestones (see the roadmap in milestones (M3 and M3.5 above; the original roadmap spec has since been removed —
`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`). code and git history are the record).
-138
View File
@@ -1,138 +0,0 @@
// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI
// and owns the commitment state machine.
package main
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"time"
"antidrift/internal/ai"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/statusfile"
"antidrift/internal/store"
"antidrift/internal/tasks"
"antidrift/internal/web"
)
const addr = "localhost:7777"
func main() {
path, err := store.DefaultPath()
if err != nil {
log.Fatalf("resolve snapshot path: %v", err)
}
ctrl, err := session.New(path)
if err != nil {
log.Fatalf("load session: %v", err)
}
srv := web.NewServer(ctrl)
srv.Init() // re-arm or expire a restored Active session
// Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// The knowledge source is a single file adapter whose path is driven entirely
// by SetKnowledgePath, so startup and live settings edits share one code path.
ctrl.SetKnowledge(knowledge.NewFileSource(""))
// applyFn re-wires the running daemon from a Settings value: AI backend +
// service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It
// validates the backend FIRST and mutates nothing on failure, so POST /settings
// can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
ctrl.SetCoach(svc)
ctrl.SetDriftJudge(svc)
ctrl.SetNudge(svc)
ctrl.SetReviewer(svc)
ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))
ctrl.SetKnowledgePath(s.KnowledgePath)
return nil
}
// Apply once at startup. A bad persisted backend should not be fatal: fall back
// to claude (always valid), persist the correction, and re-apply.
if err := applyFn(cfg); err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
if err := applyFn(cfg); err != nil {
log.Fatalf("settings: apply failed even with claude: %v", err)
}
}
srv.SetSettings(settingsPath, cfg, applyFn)
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
ctrl.SetGuard(enforce.NewGuard())
log.Printf("enforce: window-minimize guard")
src := evidence.NewSource()
go src.Watch(context.Background(), ctrl.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("antidriftd listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+250
View File
@@ -0,0 +1,250 @@
// Command keeld is the Keel daemon: it serves a local web UI and drives the
// harness — the general collect→brain→act loop that hosts one mode at a time
// (focus or offscreen).
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"keel/internal/ai"
"keel/internal/ambient"
"keel/internal/aw"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/offscreen"
"keel/internal/notify"
"keel/internal/settings"
"keel/internal/statusfile"
"keel/internal/tasks"
"keel/internal/web"
)
const addr = "localhost:7777"
func main() {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("resolve home dir: %v", err)
}
root := filepath.Join(home, ".keel")
// Resolve and load settings, seeding from the legacy KEEL_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// buildServices turns a Settings value into a fresh harness.Services. It
// validates the AI backend FIRST and returns an error before constructing any
// ports, so an invalid backend never half-rewires the harness. The same code
// path serves startup and live POST /settings edits.
buildServices := func(s settings.Settings) (harness.Services, error) {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
// Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on
// first run, but Load returns whatever the JSON file holds on later starts.
awURL := s.AWURL
if awURL == "" {
awURL = "http://localhost:5600"
}
var mem memory.Store = memory.NewNop()
awc := aw.New(awURL)
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
} else {
mem = memory.NewAWStore(awc, "keel.events")
log.Printf("memory: keel.events ready at %s", awURL)
}
return harness.Services{
AI: svc,
Tasks: tasks.NewMarvin(s.MarvinCmd),
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
Enforce: enforce.NewGuard(),
Memory: mem,
Clock: time.Now,
}, nil
}
// Build the base services at startup. A bad persisted backend should not be
// fatal: fall back to claude (always valid), persist the correction, and retry.
base, err := buildServices(cfg)
if err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
base, err = buildServices(cfg)
if err != nil {
log.Fatalf("settings: build failed even with claude: %v", err)
}
}
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
log.Printf("enforce: window-minimize guard")
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
// mode builds its own file paths under Services.Dir.
withDir := func(dir string, f harness.Factory) harness.Factory {
return func(s harness.Services) mode.Mode {
s.Dir = dir
return f(s)
}
}
focusDir := filepath.Join(root, "modes", "focus")
offscreenDir := filepath.Join(root, "modes", "offscreen")
h := harness.New(base, map[string]harness.Factory{
"focus": withDir(focusDir, focus.Factory),
"offscreen": withDir(offscreenDir, offscreen.Factory),
})
// Re-adopt a live focus session restored from disk, if one was in flight.
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
// restored mode's async completions reach the harness's onChange listeners.
focusSvc := h.Services()
focusSvc.Dir = focusDir
if m, live, err := focus.Restore(focusSvc); err != nil {
log.Printf("focus: restore failed (continuing idle): %v", err)
} else if live {
if err := h.Adopt(m); err != nil {
log.Printf("focus: adopt restored session failed: %v", err)
} else {
log.Printf("focus: restored a live session")
}
}
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
// mode is active. Backend/port changes need a daemon restart to reach it; live
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
// services").
ambMode := cfg.AmbientMode
if ambMode == "" {
ambMode = settings.AmbientNotify
}
// Compute the effective cadence so the log reflects what the sentinel will
// actually use: New clamps a non-positive value (e.g. a settings.json that
// predates these fields) to a default, mirrored here for the log line.
ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second
if ambCadence <= 0 {
ambCadence = 5 * time.Minute
}
sentinel := ambient.New(ambient.Deps{
AI: base.AI,
Knowledge: base.Knowledge,
Tasks: base.Tasks,
Notifier: notify.NewNotifier(),
Memory: base.Memory,
Clock: time.Now,
ActiveMode: func() string { return h.State().ActiveMode },
}, ambCadence, ambMode)
log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence)
srv := web.NewServer(h)
srv.Init() // re-arm or expire a restored deadline-bearing session
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
sentinel.AddOnChange(srv.Broadcast)
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
// fresh harness.Services and installs it via SetServices. Per the harness
// contract this affects the NEXT mode start; an already-active mode keeps the
// services it was built with. It validates the backend before mutating, so
// POST /settings can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
}
next, err := buildServices(s)
if err != nil {
return err
}
h.SetServices(next)
mode := s.AmbientMode
if mode == "" {
mode = settings.AmbientNotify
}
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
return nil
}
srv.SetSettings(settingsPath, cfg, applyFn)
// Mirror runtime status to ~/.keel_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
h.AddOnChange(writer.Wake)
sentinel.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
// Feed the window sensor stream into BOTH the harness (for the active mode)
// and the ambient sentinel (always-on). On a headless box the source is a
// no-op, so both degrade gracefully without new X11 requirements.
src := evidence.NewSource()
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
h.RecordWindow(w)
sentinel.OnWindow(w)
})
go sentinel.Run(context.Background())
go openBrowser("http://" + addr)
log.Printf("keeld listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+305
View File
@@ -0,0 +1,305 @@
---
title: Keel — Architecture
name: keel
category: architecture
created: 2026-06-04
updated: 2026-06-04
tags:
- keel
- human-harness
- architecture
- antidrift
- activitywatch
status: authoritative
---
# Keel — Architecture
> **Name & home.** *Keel* is the system: a human-harness that helps Felix hold
> course toward his higher goals. The name answers AntiDrift's metaphor — *drift*
> is what a vessel does with nothing to hold it; the *keel* resists drift and
> keeps you on an even keel (steady mind). **This repo is `keel`** — AntiDrift's
> focus harness becomes Keel's first *mode*. The rename is **complete**: directory,
> git repo, Go module (`keel`), binary (`keeld`), runtime (`~/.keel/`), and env
> (`KEEL_*`) are all `keel`. The old `~/.antidrift/` ledger needs a one-time manual
> move to `~/.keel/` for existing installs.
> Moved from `~/dev/higher/` on 2026-06-04 — that directory was the scaffold where
> the decision to extend AntiDrift was made, and now retires.
---
## 1. What we're building (one sentence)
**A harness** — a framework that pulls Felix's real state out of his existing
tools, hands it to a swappable brain, and turns the reply into actions and two
surfaces. The brain, the storage, and the data sources are all **reused**; the
harness is the only thing we build. The target is one system (Keel — the single
screen), grown by **loosening AntiDrift's controller**, not by starting over.
The inversion holds: this is a *human-harness*. In an agent harness a model is
the observed worker; here Felix is the observed subject and the model is the
operator.
---
## 2. Core decisions (locked 2026-06-04)
1. **The product is the harness.** The brain (`claude` / `codex` / **Hermes**) is
rented, interchangeable compute reached over a CLI or the network — *not* the
point. The value is the framework that feeds information *in* and acts on what
comes *back*. Hermes is real (an agent harness on another machine); it is one
brain option, not a dependency.
2. **Reuse AntiDrift's engine; loosen its controller.** AntiDrift already is this
harness for one plane. Its controller is welded to *commitment = next_action +
success_condition + timebox*. We loosen that into a general **collect → brain →
act** loop where a focus-session is just **one mode**. The harness now hosts a
second, **off-screen** mode; further modes (body, review, capture) are
aspirational. The core architecture is salvageable — we widen scope, we don't
rebuild.
- Worked example (the shipped non-focus mode): from the phone web UI, *"what
worthwhile off-screen thing should I do now?"* → the harness reads today's
Marvin tasks + the `~/owc` goals and life-domain `bug-*.md`, the brain
proposes one off-screen action, the UI asks Felix for feedback, and on
approval the Marvin effector files the task.
3. **Storage = ActivityWatch.** AW is already a general append-only event store
with a REST API, and it is currently the single richest *unused* asset
(~1 yr / ~287k events). Keel gets its **own AW bucket(s)** for memory. **No new
database, no new event store.** AW is therefore both a *sensor* (screen-time
ground truth) and the *store* (Keel's memory).
4. **Two surfaces, both — no fork.**
- **Web UI** — configure, interact, discuss; phone-friendly. The place Felix
*talks to* Keel.
- **WM status bar** — reflect current status (AntiDrift already writes
`~/.keel_status`). The reminder sentence lives here; it is one *status
element*, **not** a deliverable. (No "nice summaries" as a product.)
5. **Life-information root = `~/owc`** (owncloud markdown), formerly called
"clawd". Values, goals, the 22 `resources/bug-*.md`, weekly reviews,
self-management frameworks, capture stream. **Reference, never duplicate.**
6. **Effectors: start gated, trend toward autonomy.** Day one: read + *propose,
Felix confirms in the web UI*. Target: the brain does a lot on its own —
**update Beeminder datapoints, update Marvin to-dos, update `~/owc` notes.**
The gate (propose-and-confirm vs. just-do-it) is a **config surface per
effector**, not a vibe — this is the agency-vs-cage line and it stays Felix's
to set.
7. **Still in force:** don't build a new event store; AntiDrift is the substrate;
reference source-of-truth; local-first; every session ends with visible proof;
the status line stays small.
---
## 3. The harness (the components we build)
```
SOURCES (read) KEEL (the framework = the product) BRAIN (swappable)
───────────── ────────────────────────────────────── ──────────────────
AW :5600 ───────┐ ┌──────────────────────────────────────┐ claude (local CLI)
AntiDrift ──────┤ feed │ ① COLLECTORS → ③ ASSEMBLER → [BRAIN] │ send ──▶ codex (local CLI)
HQ hq.db ───────┤ ───────▶ │ ▲ │ │ Hermes (remote API)
Consume :8000 ──┤ │ │ ▼ │ ◀─ reply
daily.db :2200 ─┤ │ ② MEMORY = AW buckets ④ EFFECTORS │ (just compute;
Beeminder ──────┤ │ (reuse AW; no new DB) │ │ interchangeable)
Marvin ammcp ───┘ └────────────────────────────────┼─────┘
~/owc (frame) ──── grounds the brief ────────────────────────┤
⑤ renders to ──┴──────────┐
▼ ▼
WEB UI WM STATUS BAR
(configure/interact/discuss) (current status)
```
Each component has one job, a defined interface, and can be built/tested alone:
- **① Collectors** — one small read-only adapter per source. Contract:
`read(window) -> Signal[]`. Today's real interfaces: AW `POST :5600/api/0/query/`;
AntiDrift `tail ~/.keel/audit.jsonl` (+ live SSE `:7777/events`); HQ
read-only `hq.db` (+ `:8765`); Consume `GET :8000/api/...`; daily read-only
`daily.db` (+ `:2200`); Beeminder `beeline`; Marvin `ammcp` MCP / `am --json`.
Depends only on the source being up; degrades to a dropped signal if not.
- **② Memory** — AW buckets owned by Keel. Keel writes its own derived events here
(mode_started, proposal_made, action_taken, spiral_flagged, …) so it *remembers
across runs* and can see trends a single tool can't. Rebuildable, queryable via
AW's REST API. Depends on AW only. (See §7.)
- **③ Context assembler** — builds the brief handed to the brain = relevant
collector signals + memory (recent + trend) + the `~/owc` frame (the value/goal
at stake, the relevant `bug-*.md`). Budgets it. Depends on ① ② and `~/owc`.
Privacy boundary lives here: titles that can leak secrets are filtered before
they reach the brain.
- **Brain adapter** — pluggable. Local: shell `claude --print` / `codex exec`
(exactly what AntiDrift's `ai.Backend` already does). Remote: call Hermes over
the network. Interface: `decide(brief) -> {status, proposals[]}`. Keel treats it
as a black box; swapping it changes nothing else.
- **④ Effectors** — one gated adapter per write target. Today's safe writes:
Marvin task create/complete (`am` / `ammcp`, **preserve the `fieldUpdates`
CRDT** on write), capture append (one file), AntiDrift start-session / enforce.
Roadmap writes: Beeminder datapoint (`beeline`), `~/owc` note edits. Each
effector reads its gate from config (propose-and-confirm | auto).
- **⑤ Surfaces** — render Keel's state. Web UI (rich, phone-friendly, the
interaction/approval surface) and the WM status bar (one live line). Both read
the same Keel state; neither owns data.
- **Control loop / scheduler** — drives the cycle: when to collect, when to think,
when to surface, when to act. Cheap triggers (a switch-count spike, a Beeminder
losedate approaching, a manual web-UI request) decide when to spend a brain
call, instead of polling the LLM blindly. This is AntiDrift's state machine,
generalized past the single session.
- **Policy / gates** — per-effector authority + the value frame. Felix's dial
from "read-only" to "act freely". Configured in the web UI.
---
## 4. AntiDrift is the seed, not a peer
Keel is AntiDrift's port set, widened. The parts already exist:
| Keel part | AntiDrift today | Keel (generalized) |
|---|---|---|
| Collectors | `evidence.Source` (X11), `tasks.Provider` (Marvin) | + AW, HQ, Consume, daily, Beeminder, `~/owc` |
| Frame | `knowledge.Source` → one file | `~/owc`: values, goals, 22 `bug-*.md`, frameworks |
| Brain | `ai.Backend` → local `claude`/`codex` | + Hermes (remote); pluggable interface |
| Memory | ephemeral (dies in `state.json`) | **AW buckets** (durable, queryable) |
| Effectors | `enforce.Guard` (window-minimize) | + Marvin, capture, Beeminder, `~/owc` notes |
| Surfaces | web UI `:7777` + `~/.keel_status` | richer web UI (phone) + status bar |
| Control loop | session state machine (locked/planning/active/review) | **modes** (focus is one); a session is one mode |
**Loosening the controller** concretely means: generalize `Commitment` from a
work-session into a *mode invocation* (a mode has its own collectors, frame slice,
brain prompt, and allowed effectors); the focus-session remains the
`next_action/success_condition/timebox` mode; the off-screen mode reads the
life-domain bugs + goals and proposes one off-screen action; a future "capture"
mode would just route a thought. The runtime state machine becomes the loop in §3.
---
## 5. One loop, concrete
Off-screen mode, from the phone, end to end — **this is what ships today**:
1. **Collect** — today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md`
goals + the life-domain `~/owc/resources/bug-*.md` notes — plus recent
proposals and their outcomes recalled from the `keel.events` AW bucket —
assembled into a single budgeted brief.
2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen
action plus a short rationale.
3. **Surface** — the phone-first web UI renders a proposal card and asks Felix to
confirm or dismiss.
4. **Act** — on confirm, the Marvin `Create` effector files the action as a task
(CRDT-preserving `am add`); the mode is one-shot and the harness returns idle.
No new store touched. The brain was rented for one call. Everything else was Keel.
**Shipped:** memory. Off-screen now writes its decisions to the `keel.events` AW
bucket (`proposal_made` / `action_taken` / `proposal_dismissed`, correlated by a
random `proposal_id`) and reads its recent history back into the brief — so a
proposal can avoid repeating itself and follow up on what was dismissed or left
undone. Storage sits behind a thin `memory.Store` port (AW-backed, or a nop when
AW is down), the seam every later mode reuses; see §7. The `keel.state` bucket and
the trend/spiral detection that reads this data remain future increments.
---
## 6. Sources & targets (the real ecosystem)
| Tool | Read | Write | Role | Source of truth |
|---|---|---|---|---|
| ActivityWatch | `POST :5600/api/0/query/`, sqlite | new buckets via REST | sensor **+ store** | `peewee-sqlite.v2.db` |
| AntiDrift | `audit.jsonl`, SSE `:7777` | `POST :7777` cmds, enforce | focus mode + enforcement | `~/.keel/` |
| HQ | `hq.db` r/o, `:8765` | `dashboard.write_artifact` | agent-execution evidence | `hq.db` (~23k events) |
| Consume | `GET :8000/api` | (later) | intake/reflection signal | item frontmatter |
| daily | `daily.db` r/o, `:2200` | — | mood/habit checkout | `daily.db` |
| Beeminder | `beeline` | `beeline` datapoints | committed-evidence rollups | Beeminder API |
| Marvin | `ammcp` / `am --json` | `am` / `ammcp` (keep CRDT) | intent / to-dos / bugs | CouchDB |
| `~/owc` | markdown globs, `.zk` | note edits (later) | values/goals/bugs frame | `~/owc` files |
| Brain | — | — | rented compute | claude/codex/Hermes |
Known frictions Keel inherits (from the survey): beesync has **no scheduler**
(Beeminder data lags; `[daily]` bridge commented out); the life-bug concept is
**forked** (22 rich `~/owc` markdown bugs vs. 5 thin Marvin tasks); **capture is
broken** (`blurt` → clipboard, dead `inbox.md`, two rival `stream.md`); AW's year
of data is read by nothing today.
---
## 7. Memory in ActivityWatch (the storage design)
AW is a typed, append-only, timestamped event store with buckets + a REST/query
API — i.e. it is already the event store the §0 correction said not to rebuild.
Keel gets dedicated buckets (e.g. `keel.state`, `keel.events`) and writes
**derived** events: `mode_started`, `proposal_made`, `feedback_given`,
`action_taken`, `spiral_flagged`, `coach_line`. Properties:
- **Reference, don't duplicate** — these hold pointers (a Marvin id, a `bug-*.md`
path, an audit `session_id`), not copies of other tools' truth.
- **Rebuildable** — derivable from the underlying ledgers; AW is a cache/index of
Keel's own decisions plus a join surface over the sources.
- **Queryable over time** — this is what unlocks trend/spiral detection ("caffeine
bug + mood dip + rising switches, same shape as three weeks ago") that no single
tool can do, because each tool only answers "what now".
Open sub-decision: whether work-laptop AW data is peer-synced in (cross-device
behavioral story) or left out initially.
---
## 8. Open questions (not yet decided)
- **Effector gate policy** — exact default authority per write target on day one,
and how Felix raises/lowers it from the web UI.
- **Capture unification** — which single file is canonical (resolve
`~/owc/stream.md` vs `~/wrk/pkb/stream.md` vs dead inboxes) and rewiring `blurt`
to append there.
- **Cross-device AW** — sync work-laptop AW or not (see §7).
- **Secret hygiene** — an increasingly autonomous operator runs as the full Unix
user; plaintext keys exist on disk; the privacy boundary in ③ must be real.
### Resolved
- **Code rename** — complete as of Phase 0 of the controller refactor. Go module
is `keel`, binary is `keeld`, runtime is `~/.keel/`, env prefix is `KEEL_*`.
Existing installs with a live ledger under `~/.antidrift/` need a one-time
manual move to `~/.keel/`.
- **Controller refactor shape** — resolved: grown in place, not branched. The
focus-only `session.Controller` is now a generic `harness.Harness` that hosts
one `mode.Mode` at a time (the `collect → brain → act` loop of §3); focus is the
first mode and off-screen the second. Per-mode persistence lives under
`~/.keel/modes/<kind>/`.
---
## 9. What this supersedes (from `~/dev/higher/`)
- The whole `~/dev/higher/` planning directory: it was the scaffold for deciding
to extend AntiDrift. This doc is now the authoritative home; that dir retires to
a pointer.
- "clawd" → **`~/owc`** everywhere.
- The plan's premise that the operator "already knows Felix via `~/.hermes`" and
the survey finding "Hermes doesn't exist" are both resolved: **Hermes is real
but remote, and the brain is pluggable** — grounding comes from the `~/owc`
frame regardless of which brain runs.
- The plan's §6 recommendation ("a thin *reader* layer is the real architecture")
is **superseded**: the reader is merely the **collectors layer** of Keel. The
target is the full system (one screen, two surfaces, durable memory,
gated→autonomous effectors), grown from AntiDrift.
- "Don't build an event store" is **sharpened**: storage = **ActivityWatch
buckets**.
---
## 10. Smallest real slice (a vertical, not a summary)
To stay honest to "ship visible proof" without boiling the ocean, the first slice
is **off-screen mode, end to end** (§5) on the existing AntiDrift web surface —
collectors (`~/owc` goals + life-domain bugs + Marvin), the brain (`ai.Proposer`),
the phone-first web UI proposal card, and the Marvin `Create` effector behind a
confirm gate. It exercises every Keel component once except memory, on the
smallest mode, and produces a real filed task — interaction and action, not a
coaching sentence. **This slice now ships;** durable AW memory (§7) is the next
increment that closes the loop.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
# Controller Refactor — Harness + Modes — Design
**Date:** 2026-06-04
**Status:** Approved, ready for implementation planning
**Scope:** Rewrite the focus-only session controller into a generic
**harness** that hosts swappable **modes**. Focus becomes the first mode
(behavior-preserving extraction); **off-screen mode** becomes the second
(new, the proof the abstraction generalizes). The `antidrift``keel` code
rename and `~/.antidrift/``~/.keel/` runtime move land in the same pass,
clean-slate (no state migration).
## Problem
`session.Controller` (≈1,400 LOC across `session.go`, `roles.go`, `drift.go`,
`stats.go`, `views.go`) is a god-object. It conflates three things:
1. **Generic runtime orchestration** — holding the ports, the async
brain-call primitive (`runFetchAsync`), persistence, change-notify, the
evidence ingestion callback, the clock.
2. **The focus state machine**`locked→planning→active→transition→review`,
commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence
stats, reflection, session sealing.
3. **The focus domain payload**`domain.Commitment`
(`next_action / success_condition / timebox`), sitting in a package named
`domain` as if it were generic.
Focus mode is not *a* mode — it **is** the controller. There is no seam to add
a second mode. Keel's thesis (the harness is the product; focus is one mode
among many) cannot be expressed until (1) is separated from (2)+(3).
The directory and git repo are already `keel`; only the code identity still
says `antidrift`. AntiDrift was never in production and its `~/.antidrift/`
ledger is disposable, so the rename and runtime move can be done outright with
no migration ceremony.
## Goals
- A generic `internal/harness` host that owns shared services and exactly one
active mode, and routes I/O to it.
- A small `Mode` contract that holds both a long-lived stateful mode (focus)
and a one-shot request/response mode (off-screen) without crippling either.
- Focus re-homed as `mode/focus` with **identical behavior** — existing focus
tests are the regression oracle.
- Off-screen mode built end-to-end: collect → assemble → brain → surface →
gated Marvin write.
- The `antidrift``keel` rename complete in code and runtime, clean-slate.
## Non-goals (YAGNI)
- **AW-bucket memory** (the §7 "remember across runs" layer). Off-screen v1 is
stateless. AW memory is the immediate fast-follow, its own spec.
- **Concurrent / ambient modes.** Exactly one active mode at a time
(idle ↔ focus *or* off-screen). Matches today's single-commitment reality.
- **Any third mode.**
- **State migration** from `~/.antidrift/`. Clean slate by decision.
- Renaming the off-screen mode's working code name (`offscreen`) — placeholder,
rename freely later.
## Decisions that shaped this design
- **One thin `Mode` interface + capability interfaces**, not a stateless
collect/assemble/effector pipeline framework (Procrustean for focus's
stateful machine, and a YAGNI framework for two modes), and not two formal
mode *kinds* (fragments the concept before the seams are felt). Capability
interfaces (`EvidenceConsumer`, `Expirer`) give the heterogeneity honesty
without the taxonomy. If a third one-shot mode appears, off-screen's flow can
be promoted into a real pipeline helper then — driven by evidence.
- **Generalize the web routes** (`/mode/{kind}/start`, `/mode/command/{name}`)
rather than keep focus's bespoke endpoints. The frontend is rewritten under
the state envelope regardless.
- **`Locked` collapses into harness idle.** "No active mode" is the resting
state; focus is `Planning→Active→Review→done`. One fewer state.
- **Off-screen reuses the existing single-file `knowledge.Source`** for the
`~/owc/goals-2026.md` frame, plus a small in-package glob read
(`filepath.Glob` + read) for the curated life-bug set. `knowledge.Source.Load`
is single-path by contract; no new adapter package — smallest real slice.
- **The confirm click is the effector gate.** No auto-write in v1.
## Package layout
```
cmd/keeld/main.go # wires harness + modes + surfaces + settings
internal/harness/
harness.go # Harness: services, the one active mode, change-notify, store root
services.go # Services handle passed to modes
async.go # generation-guarded async primitive (was runFetchAsync)
internal/mode/
mode.go # Mode interface, capability interfaces, Envelope
internal/mode/focus/ # today's session/* + domain/* + statemachine/* re-homed
focus.go statemachine.go drift.go stats.go views.go roles.go ...
internal/mode/offscreen/
offscreen.go # NEW: off-screen mode
internal/ai/ tasks/ knowledge/ evidence/ enforce/ # shared ports — only module path changes
internal/store/ # persistence primitives + per-mode namespacing
internal/settings/ statusfile/ web/ # web becomes mode-aware
```
`internal/domain` dissolves into `mode/focus`: `Commitment`, `RuntimeState`,
`EnforcementLevel`, `AllowedContext`, `PolicySnapshot` are all focus concepts.
`internal/statemachine` likewise moves under `mode/focus` (its states are the
focus lifecycle). Off-screen touches none of them.
## The Mode contract
```go
package mode
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
Kind() string // "focus", "offscreen"
Command(ctx context.Context, name string, payload json.RawMessage) error
View() any // JSON-marshalable projection
Active() bool // false ⇒ harness returns to idle
}
// Optional capabilities — the harness asserts for these.
type EvidenceConsumer interface { OnWindow(evidence.WindowSnapshot) } // focus only
type Expirer interface { Deadline() time.Time; Expire(context.Context) error } // focus only
```
`Command` returns an error for illegal/invalid commands; the web layer maps it
to HTTP (today's `IllegalTransitionError`→409 mapping moves with focus). `View`
returns the mode's own shape, wrapped by the harness in the envelope below.
## The Harness + Services
```go
type Harness struct {
mu sync.Mutex
active mode.Mode // nil = idle
factories map[string]func(Services) mode.Mode // "focus", "offscreen"
services Services
onChange []func()
}
func (h *Harness) Start(kind string) error // idle → mode (single-active invariant)
func (h *Harness) Command(ctx, name, payload) error // routes to active; cleans up active→done
func (h *Harness) State() mode.Envelope
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) // forwards iff active is EvidenceConsumer
func (h *Harness) Deadline() time.Time // delegates iff active is Expirer
func (h *Harness) Expire(ctx) error // delegates iff active is Expirer
```
After any `Command`/`Start`, if `active.Active()` is false the harness clears
`active` to nil (idle). `Start` on a non-idle harness is an error unless the
requested kind is already active.
`Services` is what a mode receives — the ports plus relocated infrastructure:
```go
type Services struct {
AI *ai.Service // coach/judge/nudge/reviewer + new proposer role
Tasks tasks.Provider // read (Today) + new write (Create)
Knowledge knowledge.Source
Enforce enforce.Guard
Clock func() time.Time
Store *store.Scope // mode-namespaced (~/.keel/modes/<kind>/)
Notify func() // fires harness change-listeners (SSE + status bar)
}
```
### The async primitive
`runFetchAsync` (today `session/roles.go:99`) moves to `harness/async.go` as a
helper parameterized by **the mode's own mutex** plus `Services.Notify`:
```go
type Async struct { mu *sync.Mutex; notify func() }
func (a Async) Run(timeout time.Duration, fetch func(context.Context), stale func() bool, apply func())
```
This preserves today's exact lock discipline: `fetch` runs with no lock held;
`stale`/`apply` run under the mode's mutex; `notify` fires once, after unlock.
Each mode constructs one `Async{mu: &m.mu, notify: svc.Notify}`. Generation
counters and status enums stay inside each mode, exactly as today.
## Focus mode (behavior-preserving extraction)
`session.Controller` minus the host plumbing becomes `focus.Mode`. The state
machine, drift/nudge/enforcement, evidence stats, reflection, and the planning
coach/tasks/knowledge fetches move verbatim. Focus implements `Mode`,
`EvidenceConsumer` (today's `RecordWindow`), and `Expirer` (today's
deadline/`Expire`). Its `View()` returns today's `session.State` shape,
unchanged, so the existing focus screens render identically.
Command mapping (today's routes → focus `Command` names): `planning`, `coach`,
`commitment`, `complete`, `end`, `refocus`, `ontask`.
`Locked` is removed: harness idle replaces it. `Planning→Active→Review→done`,
then the harness returns to idle. `admin_override` stays inside focus (an
enforcement-escape concern). **Existing focus tests move with the package and
must pass unchanged** — they are the proof the extraction preserved behavior.
## Off-screen mode (the proof)
The away-from-the-desk complement to focus. A one-shot request/response
lifecycle — this is what proves the abstraction holds for a mode that is *not*
a long-lived session.
```
start → assembling → proposed → (confirm | dismiss) → done
```
- **`Command "start"`** — collect + assemble + brain via the async primitive;
view status `pending`.
- **Collect (real frame elements):**
- `tasks.Provider.Today()` — life / non-work items.
- `~/owc/goals-2026.md` (Body/Spirit, People, Via Negativa) via the existing
single-file `knowledge.Source`.
- `~/owc` life-domain bugs via a small in-package glob read:
`physical-reset`, `workout-effort`, `nutrition-effort`,
`missing-social-life`, `make-environment-beautiful`, `marriage-strategy`.
- **Brain:** a new thin `ai.Proposer` role —
`Propose(ctx, brief) → Proposal{NextAction, Rationale}` — reusing
`ai.Backend`. A sibling to `Coach`, not a bend of it.
- **Surface:** phone-friendly proposal card (off-screen ⇒ you're not at the
desk; the phone surface earns its keep here).
- **`Command "confirm"` (gated effector):** a **new**
`tasks.Provider.Create(ctx, Task)` files the proposed task in Marvin,
preserving the `fieldUpdates` CRDT on write. The confirm click *is* the gate.
- **`Command "dismiss"`:** done, nothing written.
- **Memory:** none in v1 (AW buckets deferred).
Off-screen implements only `Mode` (no evidence stream, no timebox), so it
exercises the capability-assertion path: the harness forwards neither window
events nor an expiry timer to it.
## Surfacing
```go
package mode
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"` // active mode's View()
}
```
Web routes generalize:
- `POST /mode/{kind}/start``harness.Start`
- `POST /mode/command/{name}``harness.Command` (request body = payload)
- `GET /events` → SSE of the envelope (broadcaster mechanism unchanged)
- The server-owned expiry timer arms from `harness.Deadline()` and fires
`harness.Expire()` via the `Expirer` assertion (today's `web.armExpiry`
logic, retargeted).
`app.js` switches on `active_mode`: `""` → launcher (pick focus / off-screen);
`"focus"` → today's screens, fed by `.mode`; `"offscreen"` → the proposal card.
The status-file writer renders one line per active mode (focus's drift line,
off-screen's proposal summary, or `idle`). **This is the largest single chunk
of new work**: the focus UI is reshaped under the envelope and a small
off-screen view is added.
## Persistence (clean slate)
- Root `~/.keel/`; settings `~/.keel/settings.json`; status `~/.keel_status`.
- Per-mode namespace via `store.Scope`:
`~/.keel/modes/focus/{state.json, audit.jsonl, sessions/}`.
- Off-screen v1 persists nothing (ephemeral; restart = idle).
- **No migration** from `~/.antidrift/`.
## The rename (lands in this pass)
In-repo:
- `go.mod` module `antidrift``keel`; all import paths.
- `cmd/antidriftd``cmd/keeld`; binary `keeld`; log strings.
- `store`/`settings`/`statusfile` default paths → `~/.keel*`.
- `settings.SeedFromEnv` `ANTIDRIFT_*``KEEL_*`.
- `CLAUDE.md` naming-status section (rename now *done*).
- `keel-architecture.md`: §1/§8 (rename resolved), §5/§10 (remove the phantom
"house mode"; describe off-screen mode with the real collectors above).
External — Felix updates by hand (out of repo, listed in the plan):
- WM status-bar config reading `~/.antidrift_status``~/.keel_status`.
- Any systemd unit / shell alias / launcher invoking `antidriftd``keeld`.
## Testing
- **Focus:** existing tests relocate with the package (import paths only) and
must pass unchanged — the regression oracle for the extraction.
- **Harness:** new unit tests with a fake mode — single-active invariant,
command routing, capability assertion (window events only to
`EvidenceConsumer`s; expiry only to `Expirer`s), idle↔active transitions,
`Active()==false` cleanup to idle.
- **Off-screen:** new unit tests with fake `ai`/`tasks` — collect→propose then
`confirm` files exactly one task (CRDT-preserving); `dismiss` writes nothing;
the gate holds.
- **Web:** envelope routing test (idle / focus / off-screen shapes).
## Open question carried into the plan
Build order. Recommended: (1) rename in place (mechanical, keeps tests green),
(2) extract harness + `Mode` with focus as the only mode (tests green proves
the extraction), (3) add off-screen mode, (4) reshape the frontend under the
envelope. Each step leaves the daemon runnable.
@@ -0,0 +1,298 @@
# Ambient Drift Coach — Design
**Date:** 2026-06-05
**Status:** Approved, ready for implementation planning
**Scope:** Give Keel an always-on presence. Today Keel only coaches *inside* a
declared focus session; the moment you are just working, the harness goes idle,
drops every window snapshot, and the status bar reads `idle`. This slice adds an
**ambient drift coach**: a sentinel that watches the window stream continuously
(even when no mode is active), judges your recent activity against the broad
`~/owc` frame (goals, values, life-domains) plus today's Marvin tasks, and —
when you have clearly slid off what matters — surfaces a single coaching line to
the status bar, a `notify-send` toast, and a web-UI banner you can act on.
This builds the **Control loop / scheduler** component from
`docs/keel-architecture.md` §3, which today has zero implementation, and is the
first thing that makes Keel *show up unprompted* (SOUL: "when my partner drifts,
I name it").
## Problem
The focus mode is already a capable proactive coach — local on-task matching, an
LLM drift judge, a semantic "nudge" over recent window titles, surfaced to the
status bar and web UI, all sensibly debounced. But every bit of it hangs off a
**declared `Commitment`**. You must open the UI and start a session first. With
no session:
- `harness.RecordWindow` finds no active mode and silently drops the snapshot.
- `statusfile.Render` of an empty envelope returns `idle`.
- No brain call ever happens; Keel is blind and mute.
So the common case — Felix working without ceremonially declaring a task — gets
nothing. The fix is an observer that runs regardless of the active mode and is
grounded in the standing frame instead of a per-session commitment.
## Approach (and the one real fork)
The ambient coach is **not a mode**. It is an always-on **sentinel** that runs
*beside* the one-mode harness. Two harness invariants make "ambient as a mode"
the wrong shape:
1. The harness hosts exactly one mode and drops evidence when idle — an ambient
watcher must run continuously, including when idle.
2. `harness.Start` returns `ErrBusy` if a different mode is active — an ambient
mode would either block you from starting focus, or need a special
auto-yield carve-out that erodes the single-mode contract.
A sentinel sidesteps both and matches §3, where the control loop is a distinct
component beside the harness. **Coexistence rule:** when any harness mode is
active (`h.State().ActiveMode != ""`), the sentinel stays silent — focus mode
already owns coaching during a declared session, so there is never double
coaching.
## Components
Each unit has one responsibility, a defined interface, and is testable alone.
### 1. `internal/notify` — notification effector (new)
The OS-level "interrupt me" primitive, mirroring `enforce.Guard`.
- **Interface:** `Notifier interface { Notify(ctx context.Context, title, body string) error }`
- **Linux adapter:** shells `notify-send <title> <body>`. Best-effort: returns
an error for diagnostics; callers never block on it.
- **Constructor:** `NewNotifier() Notifier` returns the real adapter when the
`notify-send` binary is found on `PATH` (via `exec.LookPath`), else a nop. On
non-Linux it is a nop. This means a missing binary degrades to "no toast,"
never a failure.
- **Nop:** `Notify` returns nil, does nothing.
- **Depends on:** `os/exec` only. Leaf package.
### 2. `internal/frame` — shared frame assembler (new; DRY extraction)
The "what matters to Felix" brief, today duplicated inside off-screen's
`collect.go`. Extracting it removes the duplication and gives the sentinel the
same grounding off-screen uses.
- **Interface:** `Assemble(ctx context.Context, know knowledge.Source, tasks tasks.Provider) string`
returns a bounded markdown brief with three sections: **Today's tasks**
(from `tasks.Today`), **Goals** (`~/owc/goals-2026.md` via `knowledge.Load`),
and **Life domains** (every `~/owc/resources/bug-*.md` via `knowledge.Load`).
- **Best-effort:** never panics, never errors; tolerates nil ports and missing
files, degrading to a short, mostly-empty brief. Same truncation cap
(`maxBriefBytes`) and rune-safe clipping off-screen uses today.
- **Refactor:** off-screen's `briefTasks` / `briefGoals` / `briefLifeDomains`
are replaced by a call to `frame.Assemble`; off-screen keeps its own
proposal-history section (`briefHistory`) prepended around it. Off-screen's
existing tests must stay green.
- **Depends on:** `knowledge`, `tasks`. Imports nothing from `ai`/`mode`.
### 3. `internal/ai` — `AmbientDrift` method (new; mirrors `Nudge`)
The brain call grounded in the frame, not a commitment.
- **Method:** `func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)`
returns `""` when the activity plausibly serves the frame (on-track), and a
one-sentence coaching line when it is a slide.
- **Prompt:** an "ambient coach" prompt stating the user has **not** declared a
task; it is handed the frame and the recent window-title sequence and asked
whether the activity serves **any** of what matters, **forgivingly**
legitimate breaks, rest, and unplanned-but-useful work are on-track. Output is
`ONLY` JSON: `{"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}`.
- **Parse (`parseAmbientDrift`):** reuses `extractJSON` and the shared
`ErrEmptyResponse` / `ErrNoJSON` sentinels. `drifting:false``""`.
`drifting:true` with a message → the message. `drifting:true` with no message →
`""` (silence) — the ambient signal is advisory, so the safe degenerate is to
say nothing, exactly like `parseNudge`.
- **Depends on:** the existing `Backend`. `ai` stays a leaf (primitives only).
### 4. `internal/ambient` — the Sentinel (new; the meat)
Always-on observer: consume evidence → tick on cadence → judge → surface.
**Construction:** `New(deps)` where deps carry `*ai.Service`, `knowledge.Source`,
`tasks.Provider`, `notify.Notifier`, `memory.Store`, `clock func() time.Time`,
and `activeMode func() string` (reads `h.State().ActiveMode` for the coexistence
rule). Config: `cadence time.Duration` and `mode` (`off` | `status` | `notify`).
**State (under one mutex):** `recentTitles []string` (consecutive-dedup ring,
capped like focus's `recentTitlesMax`), `lastWindow evidence.WindowSnapshot`,
`line string` (current drift line; `""` when on-track), `lastEvalTitles`
(snapshot of the ring at the last brain call), `wasDrifting bool`,
`episodeNotified bool`, `snoozeUntil time.Time`, `gen int`, and `onChange []func()`.
**Methods:**
- `OnWindow(snap evidence.WindowSnapshot)` — called from the evidence fan-out.
Updates `lastWindow`, appends the title to the ring (skip consecutive dup,
cap). Cheap and lock-bounded; fires no brain call.
- `Run(ctx context.Context)` — cadence loop: a ticker every `cadence` calls
`evaluate()`; returns on ctx cancel.
- `evaluate()` — the decision (see Trigger discipline below).
- `Line() string` — current ambient line for the surfaces (`""` when on-track,
quiet, or snoozed).
- `Snooze(d time.Duration)` — sets `snoozeUntil = clock()+d`; clears the line and
episode flags; fires `onChange`. While snoozed, `evaluate()` returns early.
- `SetConfig(cadence time.Duration, mode string)` — live settings update.
- `AddOnChange(f func())` — register a surface refresh (status wake, web
broadcast). Fired whenever the line changes.
**Depends on:** `ai`, `frame`, `notify`, `memory`, `evidence`, and a
`activeMode` closure. It does **not** import `harness` (closure injection keeps
it decoupled and testable).
### 5. Wiring — `cmd/keeld/main.go`
- **Build** the notifier (`notify.NewNotifier()`) and the sentinel from settings
(cadence, mode) and the shared ports (the same `ai.Service`, `knowledge`,
`tasks`, `memory`, clock used to build `harness.Services`), with
`activeMode: func() string { return h.State().ActiveMode }`.
- **Fan the evidence stream to both** the harness and the sentinel:
```go
go src.Watch(ctx, func(w evidence.WindowSnapshot) {
h.RecordWindow(w)
sentinel.OnWindow(w)
})
```
- **Run** the sentinel: `go sentinel.Run(ctx)`.
- **Surface refresh:** register both the status writer's `Wake` and the web
broadcast with `sentinel.AddOnChange(...)`, so a new ambient line re-renders
the bar and pushes over SSE promptly.
- **Live settings:** `applyFn` (the existing POST `/settings` re-wire) also calls
`sentinel.SetConfig(...)` from the new settings, so the dial takes effect
without a restart.
## Trigger discipline (aggressive, but not trigger-happy)
A `notify-send` toast on a false positive is far more irritating than a stale
status line, so `evaluate()` is conservative and brain-call-thrifty:
1. If `mode == off` → return.
2. If snoozed (`clock() < snoozeUntil`) → return.
3. If `activeMode() != ""` → a mode is coaching; clear the line (if set), fire
`onChange`, return. (Coexistence rule.)
4. **Cheap gate:** if the ring is unchanged since `lastEvalTitles` **and** we are
currently on-track (`line == ""`) → skip the brain call. This naturally
pauses calls during steady deep work *and* while AFK (no new titles arrive in
either case), while still re-checking an unresolved drift.
5. Assemble the frame (`frame.Assemble`) and call
`ai.AmbientDrift(ctx, frame, recentTitles)` under a timeout.
6. Apply the result:
- **On-track (`""`):** if a line was set, clear it and reset
`wasDrifting=false`, `episodeNotified=false`; fire `onChange`. Otherwise no-op.
- **Drifting (`msg`):**
- **New episode** (`!wasDrifting`): set `line=msg`, `wasDrifting=true`,
`episodeNotified=false`; fire `onChange`. The **status bar / banner show
immediately**, but **no toast yet**.
- **Sustained** (already `wasDrifting` on this consecutive eval): record an
`ambient_nudge` event to `memory` (`{ "message": msg, "title":
lastWindow.Title, "class": lastWindow.Class }`), and — if `mode == notify`
and `!episodeNotified` — fire exactly one `notify-send` toast and set
`episodeNotified=true`. Further sustained ticks do not re-toast.
- On a brain **error**, leave the prior line intact and log — never fabricate
drift (same discipline as `Nudge`).
7. Update `lastEvalTitles` to the current ring.
Net effect with the default ~5-minute cadence: a 40-second glance at Twitter
never pops a toast (it resolves before the second tick); a sustained ~10-minute
slide shows on the bar at the first tick and pops one toast at the second.
Memory records only *sustained* drifts, independent of the surface mode.
## Surfaces
- **Status bar (`internal/statusfile`):** `Render` gains the current ambient line
(passed in by the `Writer`, which reads `sentinel.Line`). When the envelope is
idle (`ActiveMode == ""`): a non-empty line renders `⚠ <line>`; an empty line
renders `idle` as today. Non-idle envelopes are unchanged. The `Writer` adds an
`ambientLine func() string` source and `sentinel.AddOnChange(writer.Wake)`
drives prompt re-render; the existing minute ticker still applies.
- **Web UI (`internal/web` + `static/app.js`):** the served state gains an
`"ambient": "<line>"` field (read from `sentinel.Line`), pushed over the
existing SSE channel via `sentinel.AddOnChange(broadcast)`. `app.js` renders a
**banner** when the field is non-empty: the coaching line plus two controls —
**Snooze 1h** (`POST /ambient/snooze`) and **Start focus** (reuses the existing
start-session affordance so a nudge converts into a declared session). The
banner clears automatically when the line goes empty.
- **New endpoint:** `POST /ambient/snooze` → `sentinel.Snooze(1 * time.Hour)`.
Returns 204. No body needed in v1.
## Config (the agency-vs-cage dial, §8)
`settings.Settings` gains two fields (env-seedable, mirroring the existing ones):
- `ambient_mode` (`KEEL_AMBIENT_MODE`): `off` | `status` | `notify`. Default
`notify`. Validated in the settings applier (like `ai_backend`); an unknown
value is rejected with a wrapped sentinel so POST `/settings` can map it to 400.
- `ambient_cadence_secs` (`KEEL_AMBIENT_CADENCE_SECS`): integer seconds between
evaluations. Default `300`. A non-positive value falls back to the default.
The web settings form gains a select (`ambient_mode`) and a number input
(`ambient_cadence_secs`); `applyFn` pushes both into `sentinel.SetConfig`.
## Error handling & graceful degradation
- **No `notify-send` binary / non-Linux:** nop notifier; status line and banner
still work.
- **Brain error / timeout:** `AmbientDrift` returns an error; the sentinel logs
and leaves the prior line intact. Never fabricates drift.
- **AW down:** `memory` is already the nop store; `ambient_nudge` writes are
dropped silently.
- **`knowledge` / `tasks` errors:** `frame.Assemble` degrades to a partial brief
(best-effort), never errors.
- **Headless / no X11:** the evidence source is a no-op, so `OnWindow` is never
called, the ring stays empty, the cheap gate skips every brain call, and the
sentinel is silent. No new X11 requirement.
- **Nothing in the sentinel can crash the daemon** — every external call is
best-effort and lock-bounded.
## Testing
- **`internal/ambient`** (fake clock, fake `ai` with scriptable `AmbientDrift`
returns and a call counter, fake `notify.Notifier` recording calls, fake
`tasks`/`knowledge`, `activeMode` stub, `memory.Fake`):
- on-track → no line, no toast, no memory.
- single drift tick → line set, **no** toast, no memory yet.
- two consecutive drift ticks → line set, **exactly one** toast, one
`ambient_nudge` event.
- third consecutive drift tick → still one toast (once per episode).
- back on-track → line cleared, flags reset.
- `activeMode() != ""` → no eval, line cleared.
- `mode == status` → drift sets the line but never toasts (memory still records
the sustained drift).
- `mode == off` → nothing happens, no brain call.
- snooze → no eval / no line during the window.
- cheap gate → unchanged ring + on-track → assert the fake `ai` call count does
**not** increase.
- **`internal/notify`:** `NewNotifier` with `notify-send` absent → nop; `Notify`
returns nil, no panic. (The real shell path is not unit-tested.)
- **`internal/ai`:** `parseAmbientDrift` — on-track→`""`, drift+msg→msg, drift
with no msg→`""`, empty→`ErrEmptyResponse`, no-JSON→`ErrNoJSON`.
- **`internal/frame`:** `Assemble` with fake ports — sections present; nil ports
and missing files degrade without error; truncation honored. Off-screen's
existing tests stay green after the refactor.
- **`internal/statusfile`:** idle + ambient line → `⚠ …`; idle + empty line →
`idle`; non-idle envelopes unaffected.
- **`internal/web`:** served state includes `ambient`; `POST /ambient/snooze`
routes to `Snooze` and returns 204.
- **`internal/settings`:** `ambient_mode` validation (good values accepted,
unknown rejected with the sentinel); cadence default on non-positive.
## Out of scope (deliberate, for a later slice)
- **AFK precision via the AW afk bucket.** v1 uses the cheap-gate heuristic
(no new titles ⇒ no call), which covers both AFK and deep-focus well enough.
Reading AW's input-derived afk bucket to *prove* presence is a future collector.
- **Ambient enforcement** (window-minimize). The sentinel is advisory only;
minimizing windows with no declared session would be far too cagey. Enforcement
stays a focus-session decision.
- **Trend / spiral detection** over the accumulating `ambient_nudge` history
(§7). This slice *produces* that history; reading it for cross-time patterns is
the next increment.
- **Richer banner actions** beyond snooze + start-focus (e.g. "I'm on it",
per-domain mute).
## Open questions
- **Default cadence.** 300 s is a starting guess; may want tuning after living
with it. Configurable, so cheap to change.
- **Snooze duration.** Fixed 1 h in v1; a picker is a later refinement.
@@ -0,0 +1,200 @@
# Off-screen Memory — AW-backed Recall — Design
**Date:** 2026-06-05
**Status:** Approved, ready for implementation planning
**Scope:** Give Keel its first durable, cross-run memory by wiring **one** mode
(off-screen) to a real ActivityWatch bucket. Off-screen records its own
decisions (`proposal_made` / `action_taken` / `proposal_dismissed`) to
`keel.events` and reads its recent history back into the next brief, so
proposals stay fresh and follow up on what was not acted on. The memory layer
is introduced as a thin reusable port (`memory.Store`) behind which AW, HTTP,
and bucket names are sealed — the seam every later mode reuses.
This realizes the "Next increment: memory" note in
`docs/keel-architecture.md` §5 and the storage design in §7, at the smallest
honest vertical.
## Problem
Keel has no durable memory. Mode state dies in `~/.keel/.../state.json`; the
off-screen mode cannot remember what it proposed last time, so it can repeat
itself and can never follow up ("you said you'd walk before the call — did
you?"). The architecture (§7) resolves storage to **ActivityWatch buckets**
(no new DB), because AW is an append-only, timestamped, queryable event store —
the substrate that later unlocks trend/spiral detection no single tool can do.
Today, zero AW client code exists in the repo and no `keel.*` bucket exists.
This design builds the first real slice of that memory, and only that slice.
## Decisions (locked in brainstorming 2026-06-05)
1. **Slice width = thin port + one consumer.** Add a generic `memory.Store`
port to `harness.Services` (write an event, query recent), backed by AW, but
wire exactly one real behavior: off-screen remembers. Focus and other event
kinds come later.
2. **Behavior = fresh + follow-up.** The brief includes recent proposals *and*
their outcome (confirmed / dismissed / unactioned). The brain is told to
avoid blindly repeating and to follow up on a recent unactioned/dismissed one
when it still fits.
3. **Brain-only (no UI this slice).** Memory shapes the proposal; nothing new is
rendered. A "recently proposed" UI panel is a trivial later follow-up once the
data is flowing.
4. **AW from the start, behind a port.** Not the existing JSON `store` package —
§3/§7 lock storage to AW precisely for queryability. Modes never touch HTTP.
## Architecture
Two new leaf packages, matching the existing ports-and-adapters style
(`evidence.Source`, `knowledge.Source`, `tasks.Provider`, `ai.Backend`,
`enforce.Guard`):
### `internal/aw` — thin AW REST client
No Keel concepts; just buckets and events. Grounded in the live API
(`/api/0`, AW server v0.13.2): events are `{id, timestamp, duration, data{}}`,
and `GET .../events?limit=N` returns newest-first.
```go
type Event struct {
Timestamp time.Time
Duration float64
Data map[string]any
}
type Client struct { /* baseURL, *http.Client with timeout */ }
func New(baseURL string) *Client
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error // idempotent
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error // POST .../events
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) // GET .../events?limit=N
```
- `EnsureBucket` is idempotent: creating an existing bucket is treated as
success (AW returns a benign "already exists" response).
- `Insert` posts a single event (`duration: 0` for discrete derived events).
- Any transport/HTTP-status error is returned; callers treat memory as
best-effort.
### `internal/memory` — Keel-facing port + adapters
```go
type Event struct {
Kind string // "proposal_made", "action_taken", "proposal_dismissed"
At time.Time
Data map[string]any
}
type Store interface {
Record(ctx context.Context, e Event) error
Recent(ctx context.Context, kind string, n int) ([]Event, error)
}
```
- `awStore` implements `Store` over `*aw.Client`, bucket id `keel.events`. It
maps `memory.Event ↔ aw.Event`, carrying `Kind` inside `data._kind` and the
rest of `Data` alongside. Because `keel.events` interleaves all kinds,
`Recent(kind, n)` **over-fetches** a bounded window of recent bucket events
(a fixed cap, e.g. 200), filters by `_kind` client-side, and returns up to `n`
newest. Events older than that window are not seen — acceptable for "recent",
and revisited if/when the event volume grows.
- `nopStore``Record`/`Recent` no-ops; used when AW is unreachable/disabled.
- `fake` — in-memory `Store` for tests (records appended in memory, `Recent`
filters and returns newest-first).
### Wiring
- Add `Memory memory.Store` to `harness.Services`.
- `cmd/keeld.buildServices` constructs the AW-backed store: base URL from a new
`KEEL_AW_URL` setting (default `http://localhost:5600`), then
`EnsureBucket("keel.events", "keel.event", "keel")` **once at startup**. If
that fails, log once and fall back to `nopStore` so a missing/down AW never
breaks boot.
- `offscreen.New` / `offscreen.Factory` receive `svc.Memory`.
Modes depend only on the `memory.Store` interface; AW, HTTP, and bucket names
stay sealed inside the adapter — same shape as every other port.
## Data model
`keel.events` is an append-only log of Keel's **derived** events. This slice
writes three kinds, correlated by a `proposal_id` (short random hex minted when
a proposal is made; `crypto/rand`, in the daemon, not the sandbox):
| Kind | When | `data` |
|---|---|---|
| `proposal_made` | brain returns a proposal | `{proposal_id, mode:"offscreen", next_action, rationale}` |
| `action_taken` | confirm succeeds | `{proposal_id, mode:"offscreen", next_action}` |
| `proposal_dismissed` | dismiss | `{proposal_id, mode:"offscreen"}` |
**Reference, don't duplicate (§7):** `next_action` is Keel's *own* output, not
another tool's source of truth — recording it logs Keel's decision, it does not
duplicate Marvin. The Marvin task id is *not* captured because
`tasks.Provider.Create` returns only `error`; surfacing the real id is a future
enhancement gated on that signature and is out of scope here.
## Data flow
### Write path (all best-effort)
A memory write failure is logged and swallowed; it never fails the command.
- Proposal produced (async `onComplete`, status → `proposed`): mint a
`proposal_id`, stash it on the mode, `Record(proposal_made)`.
- `confirm()`: after `tasks.Create` succeeds, `Record(action_taken)`.
- `dismiss()`: `Record(proposal_dismissed)`.
### Read path
`offscreen.assembleBrief()` gains a `## Recently proposed` section:
1. `Recent("proposal_made", 5)` (newest-first); drop entries older than ~7 days.
2. For each, resolve outcome by matching `proposal_id` against recent
`action_taken` / `proposal_dismissed` → label `confirmed` / `dismissed` /
`unactioned`, with a relative age.
3. Render lines, e.g. `- "Walk before the call" (confirmed, 2h ago)`.
4. `buildProposePrompt` gains a rule: *don't repeat these; if a recent one was
dismissed or unactioned and still fits, follow up on it instead of inventing
new.*
If `Memory` is nil or `Recent` errors, the section is omitted — the proposal
still happens, ungrounded by history.
## Error handling / degradation
Memory is never on the critical path; it degrades exactly like the `knowledge`
and `tasks` ports already do in this mode.
- AW down at **startup**`EnsureBucket` fails → log once, use `nopStore`.
Off-screen works, unremembering.
- AW down **mid-run**`Record`/`Recent` error → logged, swallowed.
Propose / confirm / dismiss all still succeed.
## Testing
- `internal/aw` — against an `httptest` server: asserts the POST event body and
the `GET ?limit=N` round-trip; an error status surfaces as an error.
- `internal/memory``awStore` against `httptest` (the `_kind` mapping;
`Recent` filters by kind); `nopStore` trivial; the `fake` exercised directly.
- `internal/mode/offscreen` — using the `fake` store: (a) `proposal_made`
written on propose, (b) `action_taken` on confirm, (c) `proposal_dismissed`
on dismiss, (d) brief contains recent proposals with outcome labels, (e)
everything still works with a nil/erroring store. Existing offscreen tests get
the `fake` (or nil) injected — no behavior regressions.
## Out of scope (named to prevent scope creep)
- No `keel.state` bucket — only `keel.events`.
- No focus-mode events; no `mode_started` / `feedback_given` / `spiral_flagged`
/ `coach_line`.
- No UI rendering of history (brain-only).
- No spiral/trend detection — the *reason* for AW, but a later slice that reads
this data.
- No cross-device AW sync, no Marvin-id capture, no assembler privacy-filter
rework.
## What ships
Off-screen mode that writes its decisions to a real AW bucket and reads its own
history back into the next proposal — fresher proposals that follow up on what
was not done. The first durable-memory vertical, end to end, and the seam every
later mode reuses.
+1 -1
View File
@@ -1,4 +1,4 @@
module antidrift module keel
go 1.26.3 go 1.26.3
+79
View File
@@ -0,0 +1,79 @@
// internal/ai/ambient.go
package ai
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// AmbientDriftJudge judges whether current activity serves the user's standing
// frame when NO task is declared. Like Nudge it takes primitives, not domain
// types, so ai stays a leaf. The returned string is a one-sentence advisory, or
// "" when the activity is plausibly on-track.
type AmbientDriftJudge interface {
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
}
// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response.
var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift")
// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend.
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) {
out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles))
if err != nil {
return "", err
}
return parseAmbientDrift(out)
}
func buildAmbientPrompt(frame string, recentTitles []string) string {
return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame goals, values, and life-domains and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift.
Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"drifting": <true or false>, "message": "<short explanation, one sentence>"}
Rules:
- drifting: true only if the recent activity serves none of the frame; false otherwise.
- message: one short sentence naming the drift. REQUIRED when drifting is true.
## Frame
` + frame + `
## Recent window titles (oldest to newest)
` + strings.Join(recentTitles, "\n")
}
type rawAmbientDrift struct {
Drifting bool `json:"drifting"`
Message string `json:"message"`
}
// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields
// "". A drift with no message degrades to "" (silence) rather than an error:
// the ambient signal is advisory, so the safe degenerate is to say nothing —
// exactly like parseNudge.
func parseAmbientDrift(s string) (string, error) {
if strings.TrimSpace(s) == "" {
return "", ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return "", ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
}
var raw rawAmbientDrift
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
}
if !raw.Drifting {
return "", nil
}
return strings.TrimSpace(raw.Message), nil
}
+68
View File
@@ -0,0 +1,68 @@
// internal/ai/ambient_test.go
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestServiceAmbientDriftSuccess(t *testing.T) {
fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`}
line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"})
if err != nil {
t.Fatalf("ambient drift: %v", err)
}
if line == "" {
t.Fatal("expected a non-empty drift line")
}
if !strings.Contains(fb.gotPrompt, "ship keel") {
t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt)
}
if !strings.Contains(fb.gotPrompt, "YouTube - Brave") {
t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt)
}
}
func TestServiceAmbientDriftBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil {
t.Fatal("want backend error")
}
}
func TestParseAmbientDrift(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr error
}{
{"on track yields empty", `{"drifting": false, "message": ""}`, "", nil},
{"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil},
{"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil},
{"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil},
{"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil},
{"empty response", " ", "", ErrEmptyResponse},
{"no json", "no braces here", "", ErrNoJSON},
{"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAmbientDrift(tt.in)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string {
} }
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) { 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)
} }
+73
View File
@@ -0,0 +1,73 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// OffscreenProposal is the brain's suggestion for one worthwhile thing to do
// away from the screen. It is deliberately distinct from Proposal (the focus
// coach's commitment shape) to avoid a name collision.
type OffscreenProposal struct {
NextAction string `json:"next_action"`
Rationale string `json:"rationale"`
}
// Proposer turns an off-screen brief (goals, life-domain notes, today's tasks)
// into a single grounded off-screen action.
type Proposer interface {
Propose(ctx context.Context, brief string) (OffscreenProposal, error)
}
// Propose makes Service satisfy Proposer over the same backend as Coach.
func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) {
out, err := s.backend.Run(ctx, buildProposePrompt(brief))
if err != nil {
return OffscreenProposal{}, err
}
return parseOffscreenProposal(out)
}
func buildProposePrompt(brief string) string {
return `You are an off-screen advisor. Read the brief below the user's goals, life-domain notes, and today's tasks and pick ONE worthwhile thing for them to do now, away from the screen.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"next_action": "<one worthwhile off-screen action to do now>", "rationale": "<one short sentence: which goal or value it serves>"}
Rules:
- next_action: a single concrete off-screen action, doable now.
- rationale: one short sentence naming the goal or value it serves.
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
## Brief
` + brief
}
// parseOffscreenProposal extracts and validates an OffscreenProposal from raw
// CLI output, mirroring parseProposal's error discipline.
func parseOffscreenProposal(s string) (OffscreenProposal, error) {
if strings.TrimSpace(s) == "" {
return OffscreenProposal{}, ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return OffscreenProposal{}, ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
var raw OffscreenProposal
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
na := strings.TrimSpace(raw.NextAction)
if na == "" {
return OffscreenProposal{}, ErrInvalidProposal
}
return OffscreenProposal{
NextAction: na,
Rationale: strings.TrimSpace(raw.Rationale),
}, nil
}
+50
View File
@@ -0,0 +1,50 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestProposeParsesProposal(t *testing.T) {
svc := NewService(&fakeBackend{out: `{"next_action":"call mum","rationale":"people goal"}`})
p, err := svc.Propose(context.Background(), "off-screen brief")
if err != nil {
t.Fatalf("Propose: %v", err)
}
if p.NextAction != "call mum" || p.Rationale != "people goal" {
t.Fatalf("proposal = %+v", p)
}
}
func TestProposeBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Propose(context.Background(), "brief"); err == nil {
t.Fatal("want backend error")
}
}
func TestProposeUnparseable(t *testing.T) {
fb := &fakeBackend{out: "I cannot help."}
if _, err := NewService(fb).Propose(context.Background(), "brief"); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err)
}
}
func TestProposePromptIncludesBrief(t *testing.T) {
fb := &fakeBackend{out: `{"next_action":"a","rationale":"b"}`}
if _, err := NewService(fb).Propose(context.Background(), "water the tomato plants"); err != nil {
t.Fatalf("Propose: %v", err)
}
if !strings.Contains(fb.gotPrompt, "water the tomato plants") {
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
}
}
func TestProposePromptHasFollowUpRule(t *testing.T) {
p := buildProposePrompt("brief")
if !strings.Contains(p, "do not simply repeat") {
t.Fatalf("prompt missing follow-up rule:\n%s", p)
}
}
+323
View File
@@ -0,0 +1,323 @@
// internal/ambient/sentinel.go
// Package ambient is Keel's always-on drift coach: a Sentinel that watches the
// window stream beside the one-mode harness and, when no mode is active, judges
// recent activity against the ~/owc frame and surfaces drift. It is not a mode —
// it runs continuously, including when the harness is idle.
package ambient
import (
"context"
"log"
"strings"
"sync"
"time"
"keel/internal/evidence"
"keel/internal/frame"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/notify"
"keel/internal/tasks"
)
// recentTitlesMax bounds the title ring (matches the focus mode's history cap).
const recentTitlesMax = 10
// evalTimeout bounds a single ambient brain call.
const evalTimeout = 30 * time.Second
// defaultCadence is used when the configured cadence is non-positive.
const defaultCadence = 5 * time.Minute
// kindAmbientNudge is the memory event recorded for a sustained ambient drift.
const kindAmbientNudge = "ambient_nudge"
// Ambient mode dial values (mirror settings.Ambient*).
const (
modeOff = "off"
modeStatus = "status"
modeNotify = "notify"
)
// AmbientDriftJudge is the brain call the sentinel needs. *ai.Service satisfies
// it; declaring it here keeps ambient decoupled from the ai package.
type AmbientDriftJudge interface {
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
}
// Deps are the ports the sentinel depends on. Knowledge and Tasks may be nil
// (frame.Assemble guards). Memory and Notifier may be nil (best-effort guards).
// ActiveMode reports the harness's active mode kind ("" = idle); nil counts as
// always-idle. Clock defaults to time.Now.
type Deps struct {
AI AmbientDriftJudge
Knowledge knowledge.Source
Tasks tasks.Provider
Notifier notify.Notifier
Memory memory.Store
Clock func() time.Time
ActiveMode func() string
}
// Sentinel watches activity and surfaces ambient drift.
type Sentinel struct {
mu sync.Mutex
deps Deps
cadence time.Duration
mode string
recent []string
lastWindow evidence.WindowSnapshot
line string
lastEvalTitles string
drifting bool
committed bool
snoozeUntil time.Time
onChange []func()
}
// New builds a sentinel. A non-positive cadence falls back to defaultCadence; an
// empty mode falls back to notify.
func New(d Deps, cadence time.Duration, mode string) *Sentinel {
if d.Clock == nil {
d.Clock = time.Now
}
if cadence <= 0 {
cadence = defaultCadence
}
return &Sentinel{deps: d, cadence: cadence, mode: normalizeMode(mode)}
}
func normalizeMode(m string) string {
if m == "" {
return modeNotify
}
return m
}
// OnWindow ingests one sensor observation: it records the latest window and
// appends the title to the dedup-capped ring. Cheap and lock-bounded; it fires
// no brain call.
func (s *Sentinel) OnWindow(w evidence.WindowSnapshot) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastWindow = w
t := strings.TrimSpace(w.Title)
if t == "" {
return
}
if n := len(s.recent); n > 0 && s.recent[n-1] == t {
return
}
s.recent = append(s.recent, t)
if len(s.recent) > recentTitlesMax {
s.recent = s.recent[len(s.recent)-recentTitlesMax:]
}
}
// Line returns the current ambient coaching line ("" when on-track, quiet, or
// snoozed) for the surfaces.
func (s *Sentinel) Line() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.line
}
// AddOnChange registers a surface refresh fired when the line changes.
func (s *Sentinel) AddOnChange(f func()) {
s.mu.Lock()
s.onChange = append(s.onChange, f)
s.mu.Unlock()
}
// fireOnChange notifies registered surfaces off the lock.
func (s *Sentinel) fireOnChange() {
s.mu.Lock()
fs := append([]func(){}, s.onChange...)
s.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// recentForTest returns a copy of the ring (test-only).
func (s *Sentinel) recentForTest() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.recent...)
}
// Run drives the cadence loop: every cadence it evaluates, until ctx is
// cancelled. It re-reads the cadence each cycle so SetConfig takes effect on the
// next tick.
func (s *Sentinel) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-time.After(s.currentCadence()):
s.evaluate(ctx)
}
}
}
func (s *Sentinel) currentCadence() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
return s.cadence
}
// SetConfig updates the cadence and mode live (next tick).
func (s *Sentinel) SetConfig(cadence time.Duration, mode string) {
s.mu.Lock()
if cadence > 0 {
s.cadence = cadence
}
s.mode = normalizeMode(mode)
s.mu.Unlock()
}
// Snooze mutes the sentinel for d: it clears the current line and episode and
// suppresses evaluation until the window elapses.
func (s *Sentinel) Snooze(d time.Duration) {
s.mu.Lock()
s.snoozeUntil = s.deps.Clock().Add(d)
changed := s.line != ""
s.line = ""
s.drifting = false
s.committed = false
s.mu.Unlock()
if changed {
s.fireOnChange()
}
}
// evaluate runs one decision cycle. See the spec's "Trigger discipline."
func (s *Sentinel) evaluate(ctx context.Context) {
s.mu.Lock()
if s.mode == modeOff {
s.mu.Unlock()
return
}
if s.deps.Clock().Before(s.snoozeUntil) {
s.mu.Unlock()
return
}
if s.activeModeLocked() != "" {
// A mode is coaching; stay silent and clear any stale line.
changed := s.clearLocked()
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
titles := append([]string(nil), s.recent...)
joined := strings.Join(titles, "\n")
// Cheap gate: nothing new to judge and we are on-track -> skip the brain call.
if joined == s.lastEvalTitles && s.line == "" {
s.mu.Unlock()
return
}
win := s.lastWindow
mode := s.mode
judge := s.deps.AI
s.mu.Unlock()
if judge == nil {
return
}
cctx, cancel := context.WithTimeout(ctx, evalTimeout)
fr := frame.Assemble(cctx, s.deps.Knowledge, s.deps.Tasks)
msg, err := judge.AmbientDrift(cctx, fr, titles)
cancel()
s.mu.Lock()
// A mode may have activated during the (off-lock) brain call; re-check so a
// result computed while idle is not surfaced over an now-active mode.
if s.activeModeLocked() != "" {
changed := s.clearLocked()
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
s.lastEvalTitles = joined
if err != nil {
s.mu.Unlock()
log.Printf("ambient: drift judge failed: %v", err)
return // never fabricate drift; leave the prior line intact
}
if msg == "" { // on-track
changed := s.clearLocked()
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
if !s.drifting { // new episode: show the line, no toast yet
s.line = msg
s.drifting = true
s.committed = false
s.mu.Unlock()
s.fireOnChange()
return
}
// Sustained drift. Commit once: record memory, and toast in notify mode.
changed := s.line != msg
s.line = msg
if s.committed {
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
s.committed = true
now := s.deps.Clock()
mem := s.deps.Memory
notifier := s.deps.Notifier
s.mu.Unlock()
if mem != nil {
ev := memory.Event{Kind: kindAmbientNudge, At: now, Data: map[string]any{
"message": msg, "title": win.Title, "class": win.Class,
}}
if e := mem.Record(ctx, ev); e != nil {
log.Printf("ambient: memory record: %v", e)
}
}
if mode == modeNotify && notifier != nil {
if e := notifier.Notify(ctx, "Keel — drift", msg); e != nil {
log.Printf("ambient: notify: %v", e)
}
}
s.fireOnChange()
}
// clearLocked resets line + episode state and reports whether the line changed.
// Caller holds mu.
func (s *Sentinel) clearLocked() bool {
changed := s.line != ""
s.line = ""
s.drifting = false
s.committed = false
return changed
}
// activeModeLocked reads the active-mode kind, treating a nil hook as idle.
// Caller holds mu (the hook does no locking on the sentinel).
func (s *Sentinel) activeModeLocked() string {
if s.deps.ActiveMode == nil {
return ""
}
return s.deps.ActiveMode()
}
+282
View File
@@ -0,0 +1,282 @@
// internal/ambient/sentinel_test.go
package ambient
import (
"context"
"testing"
"time"
"keel/internal/evidence"
"keel/internal/memory"
)
func snap(title, class string) evidence.WindowSnapshot {
return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}}
}
func TestNewLineEmpty(t *testing.T) {
s := New(Deps{}, 0, "notify")
if s.Line() != "" {
t.Fatalf("fresh sentinel line = %q, want empty", s.Line())
}
}
func TestOnWindowRingDedupesAndCaps(t *testing.T) {
s := New(Deps{}, 0, "notify")
s.OnWindow(snap("a", "x"))
s.OnWindow(snap("a", "x")) // consecutive dup ignored
s.OnWindow(snap("b", "x"))
if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("ring = %v, want [a b]", got)
}
for i := 0; i < recentTitlesMax+5; i++ {
s.OnWindow(snap(string(rune('A'+i)), "x"))
}
if got := s.recentForTest(); len(got) != recentTitlesMax {
t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax)
}
}
func TestOnWindowSkipsEmptyTitle(t *testing.T) {
s := New(Deps{}, 0, "notify")
s.OnWindow(snap("", "x"))
if got := s.recentForTest(); len(got) != 0 {
t.Fatalf("empty title must not enter ring, got %v", got)
}
}
var _ = context.Background
// --- helpers ---
type fakeJudge struct {
out []string // queued returns, consumed front-to-back
err error
calls int
}
func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
f.calls++
if f.err != nil {
return "", f.err
}
if len(f.out) == 0 {
return "", nil
}
r := f.out[0]
f.out = f.out[1:]
return r, nil
}
type fakeNotifier struct {
calls int
body string
}
func (f *fakeNotifier) Notify(_ context.Context, _, body string) error {
f.calls++
f.body = body
return nil
}
func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel {
return New(Deps{
AI: j,
Notifier: n,
Memory: mem,
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
ActiveMode: func() string { return *active },
}, time.Minute, mode)
}
// memoryFake is a minimal memory.Store recorder.
type memoryFake struct {
events []memory.Event
}
func (m *memoryFake) Record(_ context.Context, e memory.Event) error {
m.events = append(m.events, e)
return nil
}
func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil }
// --- behavioural matrix ---
func TestEvaluateOnTrackIsSilent(t *testing.T) {
j := &fakeJudge{out: []string{""}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeNotify, &active)
s.OnWindow(snap("main.go - code", "code"))
s.evaluate(context.Background())
if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 {
t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events))
}
}
func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) {
j := &fakeJudge{out: []string{"deep in YouTube"}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if s.Line() == "" {
t.Fatal("first drift must set the status line")
}
if n.calls != 0 {
t.Fatalf("first drift must NOT toast, got %d", n.calls)
}
if len(mem.events) != 0 {
t.Fatalf("first drift must not record memory yet, got %d", len(mem.events))
}
}
func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) {
j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background()) // first: line, no toast
s.evaluate(context.Background()) // second: sustained -> one toast + one memory
s.evaluate(context.Background()) // third: still drift -> NO second toast
if n.calls != 1 {
t.Fatalf("sustained drift must toast exactly once, got %d", n.calls)
}
if len(mem.events) != 1 {
t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events))
}
if mem.events[0].Kind != kindAmbientNudge {
t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge)
}
}
func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) {
j := &fakeJudge{out: []string{"drift", "drift", ""}}
n := &fakeNotifier{}
active := ""
s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
s.evaluate(context.Background())
s.evaluate(context.Background()) // on-track now
if s.Line() != "" {
t.Fatalf("return to on-track must clear the line, got %q", s.Line())
}
}
func TestEvaluateSilentWhenModeActive(t *testing.T) {
j := &fakeJudge{out: []string{"drift"}}
active := "focus"
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if j.calls != 0 {
t.Fatalf("an active mode must suppress the brain call, got %d", j.calls)
}
if s.Line() != "" {
t.Fatalf("an active mode must keep the line empty, got %q", s.Line())
}
}
func TestEvaluateStatusModeNeverToasts(t *testing.T) {
j := &fakeJudge{out: []string{"drift", "drift"}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeStatus, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
s.evaluate(context.Background())
if n.calls != 0 {
t.Fatalf("status mode must never toast, got %d", n.calls)
}
if s.Line() == "" {
t.Fatal("status mode must still show the line")
}
if len(mem.events) != 1 {
t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events))
}
}
func TestEvaluateOffModeDoesNothing(t *testing.T) {
j := &fakeJudge{out: []string{"drift"}}
active := ""
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if j.calls != 0 {
t.Fatalf("off mode must not call the brain, got %d", j.calls)
}
}
func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) {
j := &fakeJudge{out: []string{""}} // one on-track answer available
active := ""
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("main.go - code", "code"))
s.evaluate(context.Background()) // calls=1, on-track
s.evaluate(context.Background()) // unchanged ring + on-track -> skip
if j.calls != 1 {
t.Fatalf("cheap gate must skip the second call, got %d", j.calls)
}
}
// flippingJudge simulates a harness mode activating DURING the brain call: it
// returns a drift verdict but flips the active-mode flag as a side effect, so by
// the time evaluate re-acquires the lock a mode is active.
type flippingJudge struct{ active *string }
func (f *flippingJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
*f.active = "focus"
return "deep in YouTube", nil
}
// A mode activating mid-evaluation must suppress the surface: no line, no toast,
// no memory record (coexistence rule holds across the unlock-for-I/O window).
func TestEvaluateModeActivatingDuringJudgeStaysSilent(t *testing.T) {
active := ""
n := &fakeNotifier{}
mem := &memoryFake{}
s := New(Deps{
AI: &flippingJudge{active: &active},
Notifier: n,
Memory: mem,
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
ActiveMode: func() string { return active },
}, time.Minute, modeNotify)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if s.Line() != "" {
t.Fatalf("mode active after judge must leave line empty, got %q", s.Line())
}
if n.calls != 0 {
t.Fatalf("mode active after judge must not toast, got %d", n.calls)
}
if len(mem.events) != 0 {
t.Fatalf("mode active after judge must not record memory, got %d", len(mem.events))
}
}
func TestSnoozeMutesAndClears(t *testing.T) {
j := &fakeJudge{out: []string{"drift", "drift"}}
active := ""
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background()) // line set
s.Snooze(time.Hour)
if s.Line() != "" {
t.Fatalf("snooze must clear the line, got %q", s.Line())
}
callsBefore := j.calls
s.evaluate(context.Background()) // snoozed -> no call
if j.calls != callsBefore {
t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls)
}
}
+124
View File
@@ -0,0 +1,124 @@
// Package aw is a thin ActivityWatch REST client: it creates buckets and reads
// and writes events. It holds no Keel concepts, so it stays a leaf package.
package aw
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete
// derived events it is 0.
type Event struct {
Timestamp time.Time
Duration float64
Data map[string]any
}
// Client talks to an aw-server over its /api/0 REST surface.
type Client struct {
baseURL string
hc *http.Client
}
// New builds a client for the given base URL (e.g. http://localhost:5600).
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
hc: &http.Client{Timeout: 5 * time.Second},
}
}
// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists
// response (304) is success.
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error {
body, _ := json.Marshal(map[string]string{
"client": client,
"type": eventType,
"hostname": hostname(),
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified:
return nil
default:
return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode)
}
}
func hostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "keel"
}
type eventJSON struct {
Timestamp time.Time `json:"timestamp"`
Duration float64 `json:"duration"`
Data map[string]any `json:"data"`
}
// Insert posts a single event to the bucket. The AW events endpoint takes a
// JSON array, so the event is wrapped in a one-element list.
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error {
body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode)
}
return nil
}
// Recent returns up to limit events from the bucket, newest first (the AW
// default ordering for the events endpoint).
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) {
endpoint := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode)
}
var raw []eventJSON
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
out := make([]Event, len(raw))
for i, e := range raw {
out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}
}
return out, nil
}
+108
View File
@@ -0,0 +1,108 @@
package aw
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestEnsureBucketPostsCreate(t *testing.T) {
var gotPath string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("EnsureBucket: %v", err)
}
if gotPath != "/api/0/buckets/keel.events" {
t.Fatalf("path = %q", gotPath)
}
if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" {
t.Fatalf("body = %+v", gotBody)
}
}
func TestEnsureBucketTreatsExistsAsOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("existing bucket should be OK, got %v", err)
}
}
func TestEnsureBucketErrorsOnServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil {
t.Fatal("expected error on 500")
}
}
func TestInsertPostsEventArray(t *testing.T) {
var gotPath string
var gotEvents []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotEvents)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
ev := Event{Timestamp: time.Unix(1000, 0).UTC(), Data: map[string]any{"_kind": "proposal_made"}}
if err := c.Insert(context.Background(), "keel.events", ev); err != nil {
t.Fatalf("Insert: %v", err)
}
if gotPath != "/api/0/buckets/keel.events/events" {
t.Fatalf("path = %q", gotPath)
}
if len(gotEvents) != 1 || gotEvents[0]["data"].(map[string]any)["_kind"] != "proposal_made" {
t.Fatalf("events = %+v", gotEvents)
}
}
func TestRecentDecodesNewestFirst(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %q, want GET", r.Method)
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Errorf("limit = %q, want 5", got)
}
_, _ = io.WriteString(w, `[
{"id":2,"timestamp":"2026-06-05T10:00:00+00:00","duration":0,"data":{"_kind":"proposal_made"}},
{"id":1,"timestamp":"2026-06-04T10:00:00+00:00","duration":0,"data":{"_kind":"action_taken"}}
]`)
}))
defer srv.Close()
got, err := New(srv.URL).Recent(context.Background(), "keel.events", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["_kind"] != "proposal_made" {
t.Fatalf("got = %+v", got)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ package enforce
import ( import (
"context" "context"
"antidrift/internal/winapi" "keel/internal/winapi"
) )
// NewGuard returns the Windows window-minimize guard. // NewGuard returns the Windows window-minimize guard.
+5 -2
View File
@@ -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
} }
} }
+15 -1
View File
@@ -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") {
+5 -4
View File
@@ -24,14 +24,15 @@ type WindowSnapshot struct {
} }
// Source is the activity port. Watch runs until ctx is cancelled, invoking // Source is the activity port. Watch runs until ctx is cancelled, invoking
// onChange on every active-window change, and once immediately with the // onChange once immediately with the current window and then on every change of
// current window. // the active window or its title (a tab switch changes the title but not the
// active window).
type Source interface { type Source interface {
Watch(ctx context.Context, onChange func(WindowSnapshot)) Watch(ctx context.Context, onChange func(WindowSnapshot))
} }
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket // titleNoise matches the clock/ratio/percent runs that pollute bucket
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl. // keys (e.g. "1:23", "50.5%", "-3.0").
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`) var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
// extraSpace collapses the whitespace runs left behind by the scrubs below. // extraSpace collapses the whitespace runs left behind by the scrubs below.
+44
View File
@@ -0,0 +1,44 @@
package evidence
import (
"context"
"time"
)
// pollLoop samples the active window via read and forwards snapshots through
// onChange, but only when the observation changes — a different window OR a
// different title. The title case is the one that matters: switching a browser
// tab changes the window title without changing the active window, so a sensor
// that re-reads only on active-window events never sees it and credits the stale
// title. Polling re-reads on a fixed cadence, so a tab switch is caught within
// one interval.
//
// It emits once immediately, then samples every interval, until ctx is
// cancelled. WindowSnapshot is comparable, so the change check is a plain
// equality. Factoring the loop here keeps the change-dedup identical across
// sensors and lets it be tested without a display. (The Windows sensor predates
// this and keeps its own equivalent loop.)
func pollLoop(ctx context.Context, interval time.Duration, read func() WindowSnapshot, onChange func(WindowSnapshot)) {
var last WindowSnapshot
var haveLast bool
emit := func() {
s := read()
if haveLast && s == last {
return
}
last, haveLast = s, true
onChange(s)
}
emit() // immediate current window
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
emit()
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package evidence
import (
"context"
"sync"
"testing"
"time"
)
// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug
// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and
// so missed a browser tab switch — same window class, new title — crediting all
// the time to the stale title. The poll loop must emit when only the title
// changes.
func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) {
// Same window (class) throughout; the title changes mid-stream, like a tab
// switch. The repeated identical reads must NOT re-emit; the title change must.
titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"}
var i int
read := func() WindowSnapshot {
idx := i
if idx >= len(titles) {
idx = len(titles) - 1
}
i++
return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}}
}
var mu sync.Mutex
var got []WindowSnapshot
onChange := func(s WindowSnapshot) {
mu.Lock()
got = append(got, s)
mu.Unlock()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
deadline := time.Now().Add(2 * time.Second)
for {
mu.Lock()
n := len(got)
mu.Unlock()
if n >= 2 {
break
}
if time.Now().After(deadline) {
cancel()
<-done
t.Fatalf("expected >=2 emits (one per distinct title), got %d", n)
}
time.Sleep(time.Millisecond)
}
cancel()
<-done
mu.Lock()
defer mu.Unlock()
if got[0].Title != "Keel - Brave" {
t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave")
}
if got[1].Title != "Consume - Brave" {
t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave")
}
}
// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is
// not re-emitted, so redundant samples never churn the consumer.
func TestPollLoopDedupesIdenticalObservations(t *testing.T) {
read := func() WindowSnapshot {
return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}}
}
var mu sync.Mutex
var n int
onChange := func(WindowSnapshot) {
mu.Lock()
n++
mu.Unlock()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
time.Sleep(50 * time.Millisecond)
cancel()
<-done
mu.Lock()
defer mu.Unlock()
if n != 1 {
t.Fatalf("identical observations must emit once, got %d emits", n)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"context" "context"
"time" "time"
"antidrift/internal/winapi" "keel/internal/winapi"
) )
// pollInterval is how often the Windows sensor samples the foreground window. // pollInterval is how often the Windows sensor samples the foreground window.
+15 -38
View File
@@ -4,24 +4,29 @@ package evidence
import ( import (
"context" "context"
"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()
@@ -32,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 {
@@ -86,4 +64,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
Health: EvidenceHealth{Available: true}, Health: EvidenceHealth{Available: true},
} }
} }
+139
View File
@@ -0,0 +1,139 @@
// internal/frame/frame.go
// Package frame assembles the standing "what matters to Felix" brief — today's
// tasks plus the ~/owc goals and life-domain notes — that grounds a brain call.
// It is shared by the off-screen mode (which adds proposal history) and the
// ambient sentinel. Best-effort throughout: it never errors and tolerates nil
// ports and missing files.
package frame
import (
"context"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"keel/internal/knowledge"
"keel/internal/tasks"
)
// goalsPath is the standing-goals note loaded for the brief.
const goalsPath = "~/owc/goals-2026.md"
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
const bugGlob = "bug-*.md"
// MaxBriefBytes caps the assembled brief so the prompt stays bounded.
const MaxBriefBytes = 8 * 1024
// Assemble builds the frame brief from today's tasks, the standing goals note,
// and the life-domain notes. It never panics and never errors; nil ports and
// missing files degrade to a short, mostly-empty brief.
func Assemble(ctx context.Context, know knowledge.Source, tasksProvider tasks.Provider) string {
var b strings.Builder
b.WriteString("## Today's tasks\n")
b.WriteString(briefTasks(ctx, tasksProvider))
b.WriteString("\n")
if goals := briefGoals(ctx, know); goals != "" {
b.WriteString("## Goals\n")
b.WriteString(goals)
b.WriteString("\n\n")
}
if domains := briefLifeDomains(ctx, know); domains != "" {
b.WriteString("## Life domains\n")
b.WriteString(domains)
b.WriteString("\n")
}
return Truncate(b.String(), MaxBriefBytes)
}
// briefTasks lists today's task titles, or "(none)" when the port is absent,
// errors, or returns nothing.
func briefTasks(ctx context.Context, tp tasks.Provider) string {
if tp == nil {
return "(none)\n"
}
list, err := tp.Today(ctx)
if err != nil || len(list) == 0 {
return "(none)\n"
}
var b strings.Builder
for _, t := range list {
title := strings.TrimSpace(t.Title)
if title == "" {
continue
}
b.WriteString("- ")
b.WriteString(title)
b.WriteString("\n")
}
if b.Len() == 0 {
return "(none)\n"
}
return b.String()
}
// briefGoals returns the goals note text, or "" when the port is absent or the
// file is missing/empty.
func briefGoals(ctx context.Context, know knowledge.Source) string {
if know == nil {
return ""
}
p, err := know.Load(ctx, goalsPath)
if err != nil {
return ""
}
return strings.TrimSpace(p.Text)
}
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
// port (so reads honor its truncation) and concatenates their text. Returns ""
// when the port is absent or no notes are readable.
func briefLifeDomains(ctx context.Context, know knowledge.Source) string {
if know == nil {
return ""
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
if err != nil || len(matches) == 0 {
return ""
}
var b strings.Builder
for _, path := range matches {
p, err := know.Load(ctx, path)
if err != nil {
continue
}
text := strings.TrimSpace(p.Text)
if text == "" {
continue
}
b.WriteString("### ")
b.WriteString(filepath.Base(path))
b.WriteString("\n")
b.WriteString(text)
b.WriteString("\n\n")
}
return strings.TrimRight(b.String(), "\n")
}
// Truncate clips s to at most max bytes with a marker, backing up to a rune
// boundary so it never splits a multibyte rune.
func Truncate(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)"
}
+70
View File
@@ -0,0 +1,70 @@
// internal/frame/frame_test.go
package frame
import (
"context"
"strings"
"testing"
"keel/internal/knowledge"
"keel/internal/tasks"
)
// fakeTasks is a tasks.Provider returning a fixed list.
type fakeTasks struct{ list []tasks.Task }
// emptyKnowledge is a knowledge.Source that returns no text for any path, so
// Assemble emits neither a Goals nor a Life-domains section regardless of what
// files exist on the host.
type emptyKnowledge struct{}
func (emptyKnowledge) Load(context.Context, string) (knowledge.Profile, error) {
return knowledge.Profile{}, nil
}
func (f fakeTasks) Today(context.Context) ([]tasks.Task, error) { return f.list, nil }
func (f fakeTasks) Create(context.Context, tasks.Task) error { return nil }
func TestAssembleListsTodaysTasks(t *testing.T) {
tp := fakeTasks{list: []tasks.Task{{Title: "write the keel spec"}}}
got := Assemble(context.Background(), nil, tp)
if !strings.Contains(got, "## Today's tasks") {
t.Fatalf("missing tasks header:\n%s", got)
}
if !strings.Contains(got, "write the keel spec") {
t.Fatalf("missing task title:\n%s", got)
}
}
// With no knowledge source and no tasks, Assemble degrades to a short brief and
// never panics — both ports may be nil.
func TestAssembleToleratesNilPorts(t *testing.T) {
got := Assemble(context.Background(), nil, nil)
if !strings.Contains(got, "## Today's tasks") {
t.Fatalf("expected a tasks header even with no ports:\n%s", got)
}
if !strings.Contains(got, "(none)") {
t.Fatalf("expected (none) for absent tasks:\n%s", got)
}
}
func TestAssembleWithEmptyKnowledge(t *testing.T) {
got := Assemble(context.Background(), emptyKnowledge{}, fakeTasks{})
if strings.Contains(got, "## Goals") {
t.Fatalf("empty knowledge should not emit a Goals section:\n%s", got)
}
if strings.Contains(got, "## Life domains") {
t.Fatalf("empty knowledge should not emit a Life domains section:\n%s", got)
}
}
func TestTruncateClipsWithMarker(t *testing.T) {
in := strings.Repeat("x", MaxBriefBytes+100)
got := Truncate(in, MaxBriefBytes)
if len(got) > MaxBriefBytes+len("\n…(truncated)") {
t.Fatalf("truncate did not clip: len=%d", len(got))
}
if !strings.HasSuffix(got, "(truncated)") {
t.Fatalf("missing truncation marker: %q", got[len(got)-20:])
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package harness is the generic host: it owns shared services and exactly one
// active mode, and routes evidence, expiry, and commands to it.
package harness
import (
"context"
"sync"
"time"
)
// Async runs generation-guarded background work for a mode. It preserves the
// lock discipline of the original session.runFetchAsync: fetch runs with no
// lock held; stale and apply run under the mode's mutex; notify fires once,
// after the lock is released.
type Async struct {
mu *sync.Mutex
notify func()
}
// NewAsync binds the helper to a mode's mutex and the harness change-notify.
func NewAsync(mu *sync.Mutex, notify func()) Async {
return Async{mu: mu, notify: notify}
}
// Run launches a background fetch. stale returns true to DISCARD the result
// (a newer generation superseded it). apply records a non-stale result under
// the mutex and must NOT call notify — Run owns the post-unlock notify.
func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fetch(ctx)
a.mu.Lock()
if stale() {
a.mu.Unlock()
return
}
apply()
a.mu.Unlock()
if a.notify != nil {
a.notify()
}
}()
}
+48
View File
@@ -0,0 +1,48 @@
package harness
import (
"context"
"sync"
"testing"
"time"
)
func TestAsyncRunAppliesAndNotifies(t *testing.T) {
var mu sync.Mutex
notified := make(chan struct{}, 1)
a := NewAsync(&mu, func() { notified <- struct{}{} })
var applied bool
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { return false },
func() { applied = true },
)
select {
case <-notified:
case <-time.After(time.Second):
t.Fatal("notify never fired")
}
mu.Lock()
defer mu.Unlock()
if !applied {
t.Fatal("apply did not run")
}
}
func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) {
var mu sync.Mutex
a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") })
done := make(chan struct{})
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { close(done); return true },
func() { t.Fatal("apply ran on stale result") },
)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("stale guard never evaluated")
}
time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface
}
+187
View File
@@ -0,0 +1,187 @@
package harness
import (
"context"
"encoding/json"
"errors"
"sync"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
var (
ErrBusy = errors.New("harness: another mode is active")
ErrUnknownMode = errors.New("harness: unknown mode")
ErrIdle = errors.New("harness: no active mode")
)
// Factory builds a mode from the shared services, in its initial active state.
type Factory func(Services) mode.Mode
type Harness struct {
mu sync.Mutex
active mode.Mode
factories map[string]Factory
services Services
onChange []func()
}
// New wires Services.Notify to the harness so every mode's async completion
// reaches all listeners, then stores the services for the factories to receive.
func New(services Services, factories map[string]Factory) *Harness {
h := &Harness{factories: factories}
services.Notify = h.notify
h.services = services
return h
}
// AddOnChange registers a change listener (web broadcaster, status writer).
func (h *Harness) AddOnChange(f func()) {
h.mu.Lock()
h.onChange = append(h.onChange, f)
h.mu.Unlock()
}
// SetServices swaps the services future Start calls hand to factories. In v1 it
// affects the NEXT mode start only; an already-active mode keeps the services it
// was built with (settings changes take effect on the next session).
func (h *Harness) SetServices(s Services) {
s.Notify = h.notify
h.mu.Lock()
h.services = s
h.mu.Unlock()
}
// Services returns the shared services the harness hands to factories, with
// Notify wired to the harness change fan-out. Use it to build a mode for Adopt
// (e.g. startup restoration) so the restored mode's async work reaches the
// harness's onChange listeners.
func (h *Harness) Services() Services {
h.mu.Lock()
defer h.mu.Unlock()
return h.services
}
func (h *Harness) notify() {
h.mu.Lock()
fs := append([]func(){}, h.onChange...)
h.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// Adopt installs an already-constructed mode as active (startup restoration).
// It fails if a mode is already active.
func (h *Harness) Adopt(m mode.Mode) error {
h.mu.Lock()
if h.active != nil {
h.mu.Unlock()
return ErrBusy
}
h.active = m
h.mu.Unlock()
h.notify()
return nil
}
// Start activates a mode by kind. Idempotent if that kind is already active;
// ErrBusy if a different kind is active.
func (h *Harness) Start(kind string) error {
h.mu.Lock()
if h.active != nil {
busy := h.active.Kind() != kind
h.mu.Unlock()
if busy {
return ErrBusy
}
return nil
}
f, ok := h.factories[kind]
if !ok {
h.mu.Unlock()
return ErrUnknownMode
}
h.active = f(h.services)
h.mu.Unlock()
h.notify()
return nil
}
// Command routes to the active mode, then releases it if it went inactive.
func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return ErrIdle
}
if err := m.Command(ctx, name, payload); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
func (h *Harness) releaseIfDone() {
h.mu.Lock()
if h.active != nil && !h.active.Active() {
h.active = nil
}
h.mu.Unlock()
}
// State returns the surfacing envelope.
func (h *Harness) State() mode.Envelope {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return mode.Envelope{}
}
return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()}
}
// RecordWindow forwards a window snapshot iff the active mode consumes evidence.
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if c, ok := m.(mode.EvidenceConsumer); ok {
c.OnWindow(w)
}
}
// Deadline returns the active mode's deadline iff it is an Expirer.
func (h *Harness) Deadline() time.Time {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if e, ok := m.(mode.Expirer); ok {
return e.Deadline()
}
return time.Time{}
}
// Expire fires the active mode's expiry iff it is an Expirer, then releases it
// if it went inactive.
func (h *Harness) Expire() error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
e, ok := m.(mode.Expirer)
if !ok {
return ErrIdle
}
if err := e.Expire(); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package harness
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
// fakeMode implements Mode and (optionally) the capability interfaces.
type fakeMode struct {
mu sync.Mutex
kind string
active bool
cmds []string
windows int
deadline time.Time
expired bool
evidence bool // implements EvidenceConsumer when true
expirer bool // implements Expirer when true
}
func (f *fakeMode) Kind() string { return f.kind }
func (f *fakeMode) View() any { return map[string]bool{"active": f.active} }
func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active }
func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error {
f.mu.Lock()
defer f.mu.Unlock()
f.cmds = append(f.cmds, name)
if name == "finish" {
f.active = false
}
return nil
}
type evidenceMode struct{ *fakeMode }
func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() }
type expirerMode struct{ *fakeMode }
func (e expirerMode) Deadline() time.Time { return e.deadline }
func (e expirerMode) Expire() error {
e.mu.Lock()
e.expired = true
e.active = false
e.mu.Unlock()
return nil
}
func newHarness(m mode.Mode) *Harness {
return New(Services{Clock: time.Now}, map[string]Factory{
m.Kind(): func(Services) mode.Mode { return m },
})
}
func TestStartSingleActiveInvariant(t *testing.T) {
h := New(Services{}, map[string]Factory{
"a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} },
"b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} },
})
if err := h.Start("a"); err != nil {
t.Fatalf("Start a: %v", err)
}
if err := h.Start("a"); err != nil {
t.Fatalf("re-Start a should be idempotent: %v", err)
}
if err := h.Start("b"); err != ErrBusy {
t.Fatalf("Start b while a active = %v, want ErrBusy", err)
}
if got := h.State().ActiveMode; got != "a" {
t.Fatalf("ActiveMode = %q, want a", got)
}
}
func TestCommandReleasesWhenInactive(t *testing.T) {
m := &fakeMode{kind: "a", active: true}
h := newHarness(m)
_ = h.Start("a")
if err := h.Command(context.Background(), "finish", nil); err != nil {
t.Fatalf("Command: %v", err)
}
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after finish ActiveMode = %q, want idle", got)
}
}
func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic
if plain.windows != 0 {
t.Fatalf("plain mode received %d windows, want 0", plain.windows)
}
ev := evidenceMode{&fakeMode{kind: "ev", active: true}}
h2 := newHarness(ev)
_ = h2.Start("ev")
h2.RecordWindow(evidence.WindowSnapshot{})
if ev.windows != 1 {
t.Fatalf("evidence mode received %d windows, want 1", ev.windows)
}
}
func TestExpiryOnlyForExpirers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
if err := h.Expire(); err != ErrIdle {
t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err)
}
if !h.Deadline().IsZero() {
t.Fatal("non-expirer Deadline should be zero")
}
ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}}
h2 := newHarness(ex)
_ = h2.Start("ex")
if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) {
t.Fatalf("Deadline = %v, want unix 100", got)
}
if err := h2.Expire(); err != nil {
t.Fatalf("Expire: %v", err)
}
if got := h2.State().ActiveMode; got != "" {
t.Fatalf("after Expire ActiveMode = %q, want idle", got)
}
}
func TestCommandWhileIdle(t *testing.T) {
h := New(Services{}, map[string]Factory{})
if err := h.Command(context.Background(), "x", nil); err != ErrIdle {
t.Fatalf("Command while idle = %v, want ErrIdle", err)
}
}
func TestServicesNotifyReachesOnChangeListeners(t *testing.T) {
h := New(Services{}, map[string]Factory{})
fired := make(chan struct{}, 1)
h.AddOnChange(func() { fired <- struct{}{} })
svc := h.Services()
if svc.Notify == nil {
t.Fatal("Services().Notify is nil; restore path would not propagate updates")
}
svc.Notify() // simulate a restored mode's async completion firing notify
select {
case <-fired:
case <-time.After(time.Second):
t.Fatal("Services().Notify did not reach the registered onChange listener")
}
}
+27
View File
@@ -0,0 +1,27 @@
package harness
import (
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/tasks"
)
// Services is the shared infrastructure every mode receives from the harness.
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
// modes build their own file paths under it. Notify fires the harness change
// listeners (web SSE + status bar). Memory is Keel's durable cross-run event
// store (AW-backed, or a nop when AW is down).
type Services struct {
AI *ai.Service
Tasks tasks.Provider
Knowledge knowledge.Source
Enforce enforce.Guard
Memory memory.Store
Clock func() time.Time
Dir string
Notify func()
}
+3 -3
View File
@@ -22,7 +22,7 @@ type FileSource struct {
} }
// NewFileSource builds the adapter. defaultPath is used when Load receives an // 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)
+132
View File
@@ -0,0 +1,132 @@
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
// events and read them back with Recent; the AW-backed adapter persists them to
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
// them — this package stays a generic event store.
package memory
import (
"context"
"sync"
"time"
"keel/internal/aw"
)
// Event is one derived event Keel chooses to remember. Kind is the event type
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
// other tools' source of truth.
type Event struct {
Kind string
At time.Time
Data map[string]any
}
// Store records derived events and reads recent ones back by kind.
type Store interface {
Record(ctx context.Context, e Event) error
Recent(ctx context.Context, kind string, n int) ([]Event, error)
}
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
type nopStore struct{}
// NewNop returns a Store that drops writes and returns no history.
func NewNop() Store { return nopStore{} }
func (nopStore) Record(context.Context, Event) error { return nil }
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
// Fake is an in-memory Store for tests.
type Fake struct {
mu sync.Mutex
events []Event
}
// NewFake returns an empty in-memory Store.
func NewFake() *Fake { return &Fake{} }
func (f *Fake) Record(_ context.Context, e Event) error {
f.mu.Lock()
f.events = append(f.events, e)
f.mu.Unlock()
return nil
}
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Event
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
if f.events[i].Kind == kind {
out = append(out, f.events[i])
}
}
return out, nil
}
// Events returns a snapshot of everything recorded, for test assertions.
func (f *Fake) Events() []Event {
f.mu.Lock()
defer f.mu.Unlock()
return append([]Event(nil), f.events...)
}
// overfetch bounds how many recent bucket events awStore pulls before filtering
// by kind, because keel.events interleaves all kinds. Events older than this
// window are not seen — acceptable for "recent".
const overfetch = 200
// kindKey is the data field carrying the Event.Kind inside an AW event.
const kindKey = "_kind"
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
type AWClient interface {
Insert(ctx context.Context, bucketID string, e aw.Event) error
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
}
type awStore struct {
c AWClient
bucketID string
}
// NewAWStore returns a Store that persists events to the given AW bucket.
func NewAWStore(c AWClient, bucketID string) Store {
return &awStore{c: c, bucketID: bucketID}
}
func (s *awStore) Record(ctx context.Context, e Event) error {
data := make(map[string]any, len(e.Data)+1)
for k, v := range e.Data {
data[k] = v
}
data[kindKey] = e.Kind
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
}
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
if n <= 0 {
return nil, nil
}
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
if err != nil {
return nil, err
}
var out []Event
for _, ev := range raw {
if len(out) >= n {
break
}
if k, _ := ev.Data[kindKey].(string); k == kind {
data := make(map[string]any, len(ev.Data))
for dk, dv := range ev.Data {
if dk != kindKey {
data[dk] = dv
}
}
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: data})
}
}
return out, nil
}
+115
View File
@@ -0,0 +1,115 @@
package memory
import (
"context"
"testing"
"time"
"keel/internal/aw"
)
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
f := NewFake()
ctx := context.Background()
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
got, err := f.Recent(ctx, "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["n"] != "b" {
t.Fatalf("want newest-first [b,a], got %+v", got)
}
}
func TestFakeRecentRespectsLimit(t *testing.T) {
f := NewFake()
ctx := context.Background()
for i := 0; i < 4; i++ {
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
}
got, _ := f.Recent(ctx, "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("limit not honored: %d", len(got))
}
}
func TestNopStoreIsInert(t *testing.T) {
s := NewNop()
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
t.Fatalf("nop Record: %v", err)
}
got, err := s.Recent(context.Background(), "x", 5)
if err != nil || len(got) != 0 {
t.Fatalf("nop Recent = %+v, %v", got, err)
}
}
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
// recent list so we can test mapping and over-fetch filtering without HTTP.
type fakeAWClient struct {
inserted []aw.Event
recent []aw.Event // newest-first, mixed kinds
lastLimit int
}
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
f.inserted = append(f.inserted, e)
return nil
}
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
f.lastLimit = limit
return f.recent, nil
}
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
fc := &fakeAWClient{}
s := NewAWStore(fc, "keel.events")
at := time.Unix(1000, 0).UTC()
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
t.Fatalf("Record: %v", err)
}
if len(fc.inserted) != 1 {
t.Fatalf("inserted %d", len(fc.inserted))
}
got := fc.inserted[0]
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
t.Fatalf("mapped event = %+v", got)
}
}
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
fc := &fakeAWClient{recent: []aw.Event{
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
}}
s := NewAWStore(fc, "keel.events")
got, err := s.Recent(context.Background(), "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if fc.lastLimit != overfetch {
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
}
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
t.Fatalf("filtered = %+v", got)
}
if _, leaked := got[0].Data["_kind"]; leaked {
t.Fatalf("_kind leaked into returned Data: %+v", got[0].Data)
}
}
func TestAWStoreRecentCapsAtN(t *testing.T) {
var recent []aw.Event
for i := 0; i < 5; i++ {
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
}
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
got, _ := s.Recent(context.Background(), "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("cap not applied: %d", len(got))
}
}
@@ -1,5 +1,5 @@
// Package domain holds the core commitment types and validation, ported from // Package domain holds the core commitment types and validation. These are
// the original Rust implementation. These are pure data types with no I/O. // pure data types with no I/O.
package domain package domain
import ( import (
@@ -1,12 +1,12 @@
package session package focus
import ( import (
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/enforce"
"antidrift/internal/evidence"
"antidrift/internal/store"
"context" "context"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"log" "log"
"strings" "strings"
"time" "time"
@@ -31,7 +31,7 @@ const (
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching // SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
// working; unmatched windows simply stay idle. // working; unmatched windows simply stay idle.
func (c *Controller) SetDriftJudge(j ai.DriftJudge) { func (c *Mode) SetDriftJudge(j ai.DriftJudge) {
c.mu.Lock() c.mu.Lock()
c.judge = j c.judge = j
c.mu.Unlock() c.mu.Unlock()
@@ -39,7 +39,7 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize // SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
// enforcement; everything else behaves identically. // enforcement; everything else behaves identically.
func (c *Controller) SetGuard(g enforce.Guard) { func (c *Mode) SetGuard(g enforce.Guard) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.guard = g c.guard = g
@@ -47,7 +47,7 @@ func (c *Controller) SetGuard(g enforce.Guard) {
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging; // SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected. // local matching and the drift judge are unaffected.
func (c *Controller) SetNudge(n ai.Nudger) { func (c *Mode) SetNudge(n ai.Nudger) {
c.mu.Lock() c.mu.Lock()
c.nudge = n c.nudge = n
c.mu.Unlock() c.mu.Unlock()
@@ -55,7 +55,7 @@ func (c *Controller) SetNudge(n ai.Nudger) {
// commitmentLineLocked renders the active commitment as a single line for AI // commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu. // prompts, or "" if there is none. Caller holds mu.
func (c *Controller) commitmentLineLocked() string { func (c *Mode) commitmentLineLocked() string {
if c.commitment == nil { if c.commitment == nil {
return "" return ""
} }
@@ -63,29 +63,29 @@ func (c *Controller) commitmentLineLocked() string {
} }
// recentTitlesForTest returns a copy of the recent-titles ring (test-only). // recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Controller) recentTitlesForTest() []string { func (c *Mode) recentTitlesForTest() []string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return append([]string(nil), c.recentTitles...) return append([]string(nil), c.recentTitles...)
} }
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu. // resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Controller) resetDriftLocked() { func (c *Mode) resetDriftLocked() {
c.driftStatus = driftIdle c.driftStatus = driftIdle
c.driftReason = "" c.driftReason = ""
c.driftGen++ c.driftGen++
c.nudgeEpoch++ c.nudgeEpoch++
c.lastJudgedAt = time.Time{} c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{} c.judgedWindows = map[string]ai.Verdict{}
c.recentTitles = nil c.recentTitles = nil
c.nudgeMessage = "" c.nudgeMessage = ""
c.lastNudgedAt = time.Time{} c.lastNudgedAt = time.Time{}
} }
// OnTask appends the current window class to the session allowed-context, clears // OnTask appends the current window class to the session allowed-context, clears
// drift, drops any cached verdict for that class, and persists. The class now // drift, drops the cached verdict for the current window, and persists. The
// matches locally and is never re-judged. // class now matches locally and is never re-judged.
func (c *Controller) OnTask() error { func (c *Mode) OnTask() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive { if c.runtimeState != domain.RuntimeActive {
@@ -100,7 +100,7 @@ func (c *Controller) OnTask() error {
if !evidence.MatchesAllowed(ac, class, "") { if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class)) c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
} }
delete(c.judgedClasses, class) delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title))
} }
c.driftStatus = driftOnTask c.driftStatus = driftOnTask
c.driftReason = "" c.driftReason = ""
@@ -108,15 +108,15 @@ func (c *Controller) OnTask() error {
} }
// Refocus clears the current drift verdict without changing allowed-context, and // Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current class so it may be judged again later. // drops the cached verdict for the current window so it may be judged again later.
func (c *Controller) Refocus() error { func (c *Mode) Refocus() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive { if c.runtimeState != domain.RuntimeActive {
return ErrNotActive return ErrNotActive
} }
if c.stats != nil { if c.stats != nil {
delete(c.judgedClasses, c.stats.Current.Class) delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title))
} }
c.driftStatus = driftIdle c.driftStatus = driftIdle
c.driftReason = "" c.driftReason = ""
@@ -126,7 +126,7 @@ func (c *Controller) Refocus() error {
// RecordWindow ingests one sensor observation. It accumulates time only while // RecordWindow ingests one sensor observation. It accumulates time only while
// Active, always tracks the latest window for display, and fires onChange after // Active, always tracks the latest window for display, and fires onChange after
// releasing the mutex. // releasing the mutex.
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) { func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) {
c.mu.Lock() c.mu.Lock()
c.latestWindow = snap c.latestWindow = snap
if c.runtimeState != domain.RuntimeActive || c.stats == nil { if c.runtimeState != domain.RuntimeActive || c.stats == nil {
@@ -154,7 +154,7 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
// enforced — guard wired, level is block, and drift is confirmed — else nil. The // enforced — guard wired, level is block, and drift is confirmed — else nil. The
// returned func performs blocking X11 I/O and MUST run after the caller releases // returned func performs blocking X11 I/O and MUST run after the caller releases
// c.mu, following the off-lock async-I/O discipline used by the other roles. // c.mu, following the off-lock async-I/O discipline used by the other roles.
func (c *Controller) enforceActionLocked() func() { func (c *Mode) enforceActionLocked() func() {
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting { if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
return nil return nil
} }
@@ -163,7 +163,7 @@ func (c *Controller) enforceActionLocked() func() {
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout) ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
defer cancel() defer cancel()
if err := guard.MinimizeActive(ctx); err != nil { if err := guard.MinimizeActive(ctx); err != nil {
log.Printf("session: enforce minimize failed: %v", err) log.Printf("focus: enforce minimize failed: %v", err)
} }
} }
} }
@@ -173,7 +173,7 @@ func (c *Controller) enforceActionLocked() func() {
// returns the judging closure; the caller runs it in a goroutine after // returns the judging closure; the caller runs it in a goroutine after
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has // releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
// already verified the runtime is Active. // already verified the runtime is Active.
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() { func (c *Mode) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path // 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
@@ -193,8 +193,12 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.nudgeMessage = "" c.nudgeMessage = ""
c.nudgeEpoch++ c.nudgeEpoch++
// 2. Per-class cache. // 2. Per-window cache, keyed by class+title. A verdict for one window of an
if v, ok := c.judgedClasses[class]; ok { // app must not leak to a sibling window of the same class — a browser hosts
// both an on-task reading page and an off-task chat under one window class, so
// keying by class alone latched one tab's verdict onto every other tab.
key := verdictKey(class, title)
if v, ok := c.judgedWindows[key]; ok {
c.applyVerdictLocked(v) c.applyVerdictLocked(v)
return nil return nil
} }
@@ -237,13 +241,13 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.driftReason = prevReason c.driftReason = prevReason
} }
c.mu.Unlock() c.mu.Unlock()
log.Printf("session: drift judge failed: %v", err) log.Printf("focus: drift judge failed: %v", err)
c.notify() c.notify()
return return
} }
c.judgedClasses[class] = v c.judgedWindows[key] = v
var enforceAct func() var enforceAct func()
if c.stats != nil && c.stats.Current.Class == class { if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key {
c.applyVerdictLocked(v) c.applyVerdictLocked(v)
enforceAct = c.enforceActionLocked() enforceAct = c.enforceActionLocked()
} }
@@ -264,7 +268,7 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
// episode or a session change — is discarded rather than surfacing stale, never // episode or a session change — is discarded rather than surfacing stale, never
// fabricates a concern on error, and notifies only after unlocking. Caller holds // fabricates a concern on error, and notifies only after unlocking. Caller holds
// mu and has already verified the runtime is Active. // mu and has already verified the runtime is Active.
func (c *Controller) maybeNudgeLocked(now time.Time) func() { func (c *Mode) maybeNudgeLocked(now time.Time) func() {
if c.nudge == nil || len(c.recentTitles) < 2 { if c.nudge == nil || len(c.recentTitles) < 2 {
return nil return nil
} }
@@ -289,7 +293,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
} }
if err != nil { if err != nil {
c.mu.Unlock() c.mu.Unlock()
log.Printf("session: nudge failed: %v", err) log.Printf("focus: nudge failed: %v", err)
return // never fabricate a concern; leave any prior message intact return // never fabricate a concern; leave any prior message intact
} }
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
@@ -300,7 +304,7 @@ func (c *Controller) maybeNudgeLocked(now time.Time) func() {
// recordTitleLocked appends a non-empty title to the recent ring, skipping a // recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu. // consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Controller) recordTitleLocked(title string) { func (c *Mode) recordTitleLocked(title string) {
t := strings.TrimSpace(title) t := strings.TrimSpace(title)
if t == "" { if t == "" {
return return
@@ -314,8 +318,17 @@ func (c *Controller) recordTitleLocked(title string) {
} }
} }
// verdictKey identifies the judged window for the per-window verdict cache. It
// combines the window class with the scrubbed, casefolded title so sibling
// windows of one app (a browser's reading tab vs its chat tab) are judged
// independently, while transient title noise (clocks, counts) does not split a
// window into ever-changing keys that defeat the cache.
func verdictKey(class, title string) string {
return strings.ToLower(strings.TrimSpace(class)) + "\x00" + strings.ToLower(evidence.ScrubTitle(title))
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu. // applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) { func (c *Mode) applyVerdictLocked(v ai.Verdict) {
if v.OnTask { if v.OnTask {
c.driftStatus = driftOnTask c.driftStatus = driftOnTask
c.driftReason = "" c.driftReason = ""
+50
View File
@@ -0,0 +1,50 @@
package focus
import (
"path/filepath"
"keel/internal/harness"
"keel/internal/mode"
)
// build constructs a focus Mode under the mode's namespaced dir and injects the
// shared services through the existing setters (the same API tests use).
func build(svc harness.Services) (*Mode, error) {
m, err := New(filepath.Join(svc.Dir, "state.json"))
if err != nil {
return nil, err
}
if svc.Clock != nil {
m.SetClock(svc.Clock)
}
m.SetCoach(svc.AI)
m.SetReviewer(svc.AI)
m.SetDriftJudge(svc.AI)
m.SetNudge(svc.AI)
m.SetTasks(svc.Tasks)
m.SetKnowledge(svc.Knowledge)
m.SetGuard(svc.Enforce)
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m, nil
}
// Factory builds a fresh focus mode entering Planning (used by harness.Start).
func Factory(svc harness.Services) mode.Mode {
m, err := build(svc)
if err != nil {
m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works
m.SetOnChange(svc.Notify)
}
_ = m.EnterPlanning()
return m
}
// Restore loads a persisted focus session and reports whether one was live
// (Planning/Active/Transition/Review). Used at startup to re-adopt a session.
func Restore(svc harness.Services) (*Mode, bool, error) {
m, err := build(svc)
if err != nil {
return nil, false, err
}
return m, m.Active(), nil
}
@@ -1,8 +1,8 @@
// Package session owns the daemon's in-memory state of truth and persists a // Package focus owns the daemon's in-memory state of truth and persists a
// snapshot on every change. Transitions go through the pure statemachine. It // snapshot on every change. Transitions go through the pure statemachine. It
// also owns per-session evidence stats: it accumulates active-window time while // also owns per-session evidence stats: it accumulates active-window time while
// Active, logs raw focus events, and seals each session into the audit chain. // Active, logs raw focus events, and seals each session into the audit chain.
package session package focus
import ( import (
"errors" "errors"
@@ -12,14 +12,15 @@ import (
"sync" "sync"
"time" "time"
"antidrift/internal/ai" "keel/internal/ai"
"antidrift/internal/domain" "keel/internal/enforce"
"antidrift/internal/enforce" "keel/internal/evidence"
"antidrift/internal/evidence" "keel/internal/harness"
"antidrift/internal/knowledge" "keel/internal/knowledge"
"antidrift/internal/statemachine" "keel/internal/mode/focus/domain"
"antidrift/internal/store" "keel/internal/mode/focus/statemachine"
"antidrift/internal/tasks" "keel/internal/store"
"keel/internal/tasks"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -29,13 +30,14 @@ const (
sessionRetention = 30 * 24 * time.Hour sessionRetention = 30 * 24 * time.Hour
) )
var ErrNotPlanning = errors.New("session: coaching is only available while planning") var ErrNotPlanning = errors.New("focus: coaching is only available while planning")
var ErrNotActive = errors.New("session: only available while a commitment is active") var ErrNotActive = errors.New("focus: only available while a commitment is active")
// Controller holds runtime state and the active commitment behind a mutex. // Mode holds runtime state and the active commitment behind a mutex.
type Controller struct { type Mode struct {
mu sync.Mutex mu sync.Mutex
async harness.Async
runtimeState domain.RuntimeState runtimeState domain.RuntimeState
commitment *domain.Commitment commitment *domain.Commitment
deadline time.Time deadline time.Time
@@ -43,7 +45,7 @@ type Controller struct {
auditPath string auditPath string
sessionsDir string sessionsDir string
clock func() time.Time clock func() time.Time
onChange func() onChanges []func()
latestWindow evidence.WindowSnapshot latestWindow evidence.WindowSnapshot
stats *EvidenceStats stats *EvidenceStats
outcomePending string outcomePending string
@@ -80,7 +82,7 @@ type Controller struct {
driftGen int driftGen int
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
lastJudgedAt time.Time lastJudgedAt time.Time
judgedClasses map[string]ai.Verdict judgedWindows map[string]ai.Verdict
nudge ai.Nudger nudge ai.Nudger
recentTitles []string // in-memory ring of recent distinct titles this session recentTitles []string // in-memory ring of recent distinct titles this session
@@ -90,13 +92,13 @@ type Controller struct {
// New loads any persisted snapshot, prunes stale session logs, and rebuilds // New loads any persisted snapshot, prunes stale session logs, and rebuilds
// in-memory stats from the raw log if a live session was interrupted. // in-memory stats from the raw log if a live session was interrupted.
func New(snapshotPath string) (*Controller, error) { func New(snapshotPath string) (*Mode, error) {
s, err := store.Load(snapshotPath) s, err := store.Load(snapshotPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dir := filepath.Dir(snapshotPath) dir := filepath.Dir(snapshotPath)
c := &Controller{ c := &Mode{
runtimeState: s.RuntimeState, runtimeState: s.RuntimeState,
commitment: s.Commitment, commitment: s.Commitment,
snapshotPath: snapshotPath, snapshotPath: snapshotPath,
@@ -114,12 +116,13 @@ func New(snapshotPath string) (*Controller, error) {
if c.runtimeState == "" { if c.runtimeState == "" {
c.runtimeState = domain.RuntimeLocked c.runtimeState = domain.RuntimeLocked
} }
c.async = harness.NewAsync(&c.mu, c.notify)
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
c.allowedClasses = s.AllowedWindowClasses c.allowedClasses = s.AllowedWindowClasses
c.enforcementLevel = s.EnforcementLevel c.enforcementLevel = s.EnforcementLevel
// Drift state is not persisted: recompute fresh after restart to avoid // Drift state is not persisted: recompute fresh after restart to avoid
// stale interrupts. This also initializes judgedClasses (the pipeline // stale interrupts. This also initializes judgedWindows (the pipeline
// writes to it) so a restored Active session never panics on a nil map. // writes to it) so a restored Active session never panics on a nil map.
c.resetDriftLocked() c.resetDriftLocked()
c.replayStats(s.SessionID) c.replayStats(s.SessionID)
@@ -129,44 +132,56 @@ func New(snapshotPath string) (*Controller, error) {
// SetClock overrides the time source (tests only). Call before starting a // SetClock overrides the time source (tests only). Call before starting a
// session. // session.
func (c *Controller) SetClock(f func() time.Time) { func (c *Mode) SetClock(f func() time.Time) {
c.mu.Lock() c.mu.Lock()
c.clock = f c.clock = f
c.mu.Unlock() c.mu.Unlock()
} }
// SetOnChange registers a callback fired after an evidence-driven state change // SetOnChange registers the sole change listener, replacing any already set. A
// (focus updates). It is invoked with the mutex released. // listener is fired after a state change (focus updates, role results) with the
func (c *Controller) SetOnChange(f func()) { // mutex released. Use AddOnChange to register additional listeners.
func (c *Mode) SetOnChange(f func()) {
c.mu.Lock() c.mu.Lock()
c.onChange = f c.onChanges = []func(){f}
c.mu.Unlock() c.mu.Unlock()
} }
func (c *Controller) notify() { // AddOnChange registers an additional change listener alongside any already set,
// so several consumers (the web broadcaster, the status-file writer) all react
// to the same notification rather than only the last one wired.
func (c *Mode) AddOnChange(f func()) {
c.mu.Lock() c.mu.Lock()
f := c.onChange c.onChanges = append(c.onChanges, f)
c.mu.Unlock() c.mu.Unlock()
}
func (c *Mode) notify() {
c.mu.Lock()
fs := append([]func(){}, c.onChanges...)
c.mu.Unlock()
for _, f := range fs {
if f != nil { if f != nil {
f() f()
} }
}
} }
// State returns the current broadcastable state. Safe for concurrent use. // State returns the current broadcastable state. Safe for concurrent use.
func (c *Controller) State() State { func (c *Mode) State() State {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.stateLocked() return c.stateLocked()
} }
// Deadline returns the active commitment deadline, or the zero time. // Deadline returns the active commitment deadline, or the zero time.
func (c *Controller) Deadline() time.Time { func (c *Mode) Deadline() time.Time {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.deadline return c.deadline
} }
func (c *Controller) persistLocked() error { func (c *Mode) persistLocked() error {
snap := store.Snapshot{ snap := store.Snapshot{
RuntimeState: c.runtimeState, RuntimeState: c.runtimeState,
Commitment: c.commitment, Commitment: c.commitment,
@@ -187,7 +202,7 @@ func (c *Controller) persistLocked() error {
} }
// EnterPlanning moves Locked -> Planning. // EnterPlanning moves Locked -> Planning.
func (c *Controller) EnterPlanning() error { func (c *Mode) EnterPlanning() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning) next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
@@ -202,7 +217,7 @@ func (c *Controller) EnterPlanning() error {
} }
// AllowedClassesForTest exposes the session allowed classes for tests. // AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string { func (c *Mode) AllowedClassesForTest() []string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return append([]string(nil), c.allowedClasses...) return append([]string(nil), c.allowedClasses...)
@@ -210,7 +225,7 @@ func (c *Controller) AllowedClassesForTest() []string {
// EnforcementLevelForTest exposes the active session's enforcement level. Tests // EnforcementLevelForTest exposes the active session's enforcement level. Tests
// only. // only.
func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel { func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.enforcementLevel return c.enforcementLevel
@@ -219,7 +234,7 @@ func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
// StartManualCommitment validates input, activates a new commitment, mints a // StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning -> // session, seeds evidence stats from the latest window, and moves Planning ->
// Active. // Active.
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error { func (c *Mode) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
commitment, err := domain.NewManual(nextAction, successCondition, timebox) commitment, err := domain.NewManual(nextAction, successCondition, timebox)
@@ -259,12 +274,12 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
} }
// Complete moves Active -> Review with a "completed" outcome. // Complete moves Active -> Review with a "completed" outcome.
func (c *Controller) Complete() error { return c.enterReview("completed") } func (c *Mode) Complete() error { return c.enterReview("completed") }
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed). // Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
func (c *Controller) Expire() error { return c.enterReview("expired") } func (c *Mode) Expire() error { return c.enterReview("expired") }
func (c *Controller) enterReview(outcome string) error { func (c *Mode) enterReview(outcome string) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
@@ -291,7 +306,7 @@ func (c *Controller) enterReview(outcome string) error {
// End moves Review -> Locked, writes the session summary to the audit chain, // End moves Review -> Locked, writes the session summary to the audit chain,
// and clears the commitment and stats. // and clears the commitment and stats.
func (c *Controller) End() error { func (c *Mode) End() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod) next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
@@ -302,7 +317,7 @@ func (c *Controller) End() error {
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil { if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
// State integrity over audit completeness: the transition still // State integrity over audit completeness: the transition still
// completes. Surfaced for the operator; no auto-retry in M1. // completes. Surfaced for the operator; no auto-retry in M1.
log.Printf("session: audit append failed: %v", err) log.Printf("focus: audit append failed: %v", err)
} }
} }
c.runtimeState = next c.runtimeState = next
@@ -316,7 +331,7 @@ func (c *Controller) End() error {
return c.persistLocked() return c.persistLocked()
} }
func (c *Controller) buildSummaryLocked() store.SessionSummary { func (c *Mode) buildSummaryLocked() store.SessionSummary {
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets)) buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
for k, d := range c.stats.Buckets { for k, d := range c.stats.Buckets {
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
@@ -1,4 +1,4 @@
package session package focus
import ( import (
"context" "context"
@@ -11,15 +11,15 @@ import (
"testing" "testing"
"time" "time"
"antidrift/internal/ai" "keel/internal/ai"
"antidrift/internal/domain" "keel/internal/evidence"
"antidrift/internal/evidence" "keel/internal/knowledge"
"antidrift/internal/knowledge" "keel/internal/mode/focus/domain"
"antidrift/internal/store" "keel/internal/store"
"antidrift/internal/tasks" "keel/internal/tasks"
) )
func newTestController(t *testing.T) (*Controller, string) { func newTestController(t *testing.T) (*Mode, string) {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
c, err := New(path) c, err := New(path)
@@ -271,7 +271,7 @@ func (f *fakeCoach) grounding() string {
} }
// waitCoachStatus polls until the coach view reaches want, or fails after 2s. // waitCoachStatus polls until the coach view reaches want, or fails after 2s.
func waitCoachStatus(t *testing.T, c *Controller, want string) State { func waitCoachStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -386,6 +386,29 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
} }
} }
func TestPlanningStateExposesCurrentWindowClass(t *testing.T) {
c, _ := newTestController(t)
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
st := c.State()
if st.Evidence == nil || st.Evidence.Current.Class != "Brave-browser" {
t.Fatalf("planning should expose the live window class so the user can pick a matching token, got %+v", st.Evidence)
}
}
func TestAddOnChangeFansOutToAllListeners(t *testing.T) {
c, _ := newTestController(t)
var a, b int
c.SetOnChange(func() { a++ })
c.AddOnChange(func() { b++ })
c.notify()
if a != 1 || b != 1 {
t.Fatalf("every registered listener should fire: a=%d b=%d", a, b)
}
}
func TestOnTaskAppendsCurrentClass(t *testing.T) { func TestOnTaskAppendsCurrentClass(t *testing.T) {
c, _ := newTestController(t) c, _ := newTestController(t)
_ = c.EnterPlanning() _ = c.EnterPlanning()
@@ -419,7 +442,7 @@ func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title str
return f.verdict, f.err return f.verdict, f.err
} }
func waitDriftStatus(t *testing.T, c *Controller, want string) State { func waitDriftStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -433,12 +456,12 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State {
return State{} return State{}
} }
func startActive(t *testing.T, c *Controller, allowed []string) { func startActive(t *testing.T, c *Mode, allowed []string) {
t.Helper() t.Helper()
startActiveLevel(t, c, allowed, domain.EnforcementWarn) startActiveLevel(t, c, allowed, domain.EnforcementWarn)
} }
func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) { func startActiveLevel(t *testing.T, c *Mode, allowed []string, level domain.EnforcementLevel) {
t.Helper() t.Helper()
if err := c.EnterPlanning(); err != nil { if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err) t.Fatalf("planning: %v", err)
@@ -478,7 +501,54 @@ func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
} }
} }
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) { // titleJudge returns a verdict chosen by which substring the title contains,
// so a test can model an app (e.g. a browser) hosting both on- and off-task
// windows under one window class.
type titleJudge struct {
byTitle map[string]ai.Verdict
calls int32
}
func (j *titleJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
atomic.AddInt32(&j.calls, 1)
for sub, v := range j.byTitle {
if strings.Contains(title, sub) {
return v, nil
}
}
return ai.Verdict{OnTask: true}, nil
}
// A brief off-task window must not poison every later same-class window. Teams
// and a Consume reading page are both class "Brave-browser"; caching the judge
// verdict by class alone made the on-task page inherit Teams's drift verdict
// (and never re-judge), latching drift for the rest of the session.
func TestVerdictCacheKeysOnTitleNotClassAlone(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fj := &titleJudge{byTitle: map[string]ai.Verdict{
"Teams": {OnTask: false, Reason: "Teams is off-task"},
"Consume": {OnTask: true},
}}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"}) // Brave-browser never matches locally
c.RecordWindow(obs("Brave-browser", "Microsoft Teams - Brave"))
waitDriftStatus(t, c, "drifting")
now = now.Add(2 * driftDebounce) // clear debounce so a re-judge is allowed
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
st := waitDriftStatus(t, c, "ontask")
if st.Drift.Reason != "" {
t.Fatalf("on-task window must not carry the Teams reason: %+v", st.Drift)
}
if got := atomic.LoadInt32(&fj.calls); got != 2 {
t.Fatalf("different title under same class should re-judge, calls=%d", got)
}
}
func TestPerWindowCacheAvoidsSecondJudge(t *testing.T) {
c, _ := newTestController(t) c, _ := newTestController(t)
now := time.Now() now := time.Now()
c.SetClock(func() time.Time { return now }) c.SetClock(func() time.Time { return now })
@@ -488,12 +558,13 @@ func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
c.RecordWindow(obs("firefox", "YouTube")) c.RecordWindow(obs("firefox", "YouTube"))
waitDriftStatus(t, c, "drifting") waitDriftStatus(t, c, "drifting")
// Advance past the debounce window so a second judge call would be allowed: // Advance past the debounce window so a second judge call would be allowed:
// this isolates the per-class cache as the only reason the judge is skipped. // this isolates the per-window cache as the only reason the judge is skipped.
now = now.Add(2 * driftDebounce) now = now.Add(2 * driftDebounce)
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache c.RecordWindow(obs("Code", "main.go")) // switch away (local on-task match)
c.RecordWindow(obs("firefox", "YouTube")) // same window, should hit cache
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fj.calls); got != 1 { if got := atomic.LoadInt32(&fj.calls); got != 1 {
t.Fatalf("cached class should not re-judge, calls=%d", got) t.Fatalf("cached window should not re-judge, calls=%d", got)
} }
} }
@@ -554,7 +625,7 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
t.Fatalf("reload: %v", err) t.Fatalf("reload: %v", err)
} }
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedWindows map
waitDriftStatus(t, second, "drifting") waitDriftStatus(t, second, "drifting")
} }
@@ -681,7 +752,7 @@ func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, er
return f(ctx, c, t) return f(ctx, c, t)
} }
func waitNudge(t *testing.T, c *Controller, want string) { func waitNudge(t *testing.T, c *Mode, want string) {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -714,8 +785,8 @@ func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
startActive(t, c, []string{"code"}) startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube")) c.RecordWindow(obs("firefox", "YouTube"))
c.RecordWindow(obs("firefox", "Reddit"))
waitDriftStatus(t, c, "drifting") waitDriftStatus(t, c, "drifting")
c.RecordWindow(obs("firefox", "Reddit")) // second off-task title builds nudge history
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fn.calls); got != 0 { if got := atomic.LoadInt32(&fn.calls); got != 0 {
t.Fatalf("nudge must not run on the drift path, calls=%d", got) t.Fatalf("nudge must not run on the drift path, calls=%d", got)
@@ -892,8 +963,10 @@ func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return f.list, f.err return f.list, f.err
} }
func (f *fakeProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s. // waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
func waitTasksStatus(t *testing.T, c *Controller, want string) State { func waitTasksStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -1115,7 +1188,7 @@ func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile,
} }
// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s. // waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s.
func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State { func waitKnowledgeStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -1218,7 +1291,7 @@ func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai
} }
// waitReflectionStatus polls until the reflection view reaches want, or fails after 2s. // waitReflectionStatus polls until the reflection view reaches want, or fails after 2s.
func waitReflectionStatus(t *testing.T, c *Controller, want string) State { func waitReflectionStatus(t *testing.T, c *Mode, want string) State {
t.Helper() t.Helper()
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -1232,7 +1305,7 @@ func waitReflectionStatus(t *testing.T, c *Controller, want string) State {
return State{} return State{}
} }
func driveToReview(t *testing.T, c *Controller) { func driveToReview(t *testing.T, c *Mode) {
t.Helper() t.Helper()
if err := c.EnterPlanning(); err != nil { if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err) t.Fatalf("planning: %v", err)
@@ -1484,7 +1557,8 @@ func TestRecordWindowCreditsSplitFaithfully(t *testing.T) {
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
clk.advance(8 * time.Minute) clk.advance(8 * time.Minute)
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task
waitDriftStatus(t, c, "drifting") // Reddit is a distinct window: re-judged off-task before its segment is credited
clk.advance(4 * time.Minute) clk.advance(4 * time.Minute)
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
t.Fatalf("complete: %v", err) t.Fatalf("complete: %v", err)
+82
View File
@@ -0,0 +1,82 @@
package focus
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"keel/internal/evidence"
"keel/internal/mode"
"keel/internal/mode/focus/domain"
)
// Compile-time assertions: focus.Mode must satisfy the harness contracts.
var _ mode.Mode = (*Mode)(nil)
var _ mode.EvidenceConsumer = (*Mode)(nil)
var _ mode.Expirer = (*Mode)(nil)
// Kind identifies this mode to the harness.
func (c *Mode) Kind() string { return "focus" }
var errBadPayload = errors.New("focus: invalid command payload")
type commitmentPayload struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
Enforce bool `json:"enforce"`
}
// Command maps surface command names onto the existing focus transitions.
func (c *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
switch name {
case "planning":
return c.EnterPlanning()
case "coach":
var r struct {
Intent string `json:"intent"`
}
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
return c.RequestCoach(r.Intent)
case "commitment":
var r commitmentPayload
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
level := domain.EnforcementWarn
if r.Enforce {
level = domain.EnforcementBlock
}
return c.StartManualCommitment(r.NextAction, r.SuccessCondition,
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
case "complete":
return c.Complete()
case "end":
return c.End()
case "refocus":
return c.Refocus()
case "ontask":
return c.OnTask()
default:
return fmt.Errorf("focus: unknown command %q", name)
}
}
// View returns the focus state projection (the existing State shape).
func (c *Mode) View() any { return c.State() }
// Active is true from Planning through Review. End() drives the runtime back to
// Locked, which the harness reads as "done" and releases.
func (c *Mode) Active() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.runtimeState != domain.RuntimeLocked
}
// OnWindow forwards the sensor stream to the existing evidence recorder.
func (c *Mode) OnWindow(w evidence.WindowSnapshot) { c.RecordWindow(w) }
@@ -1,14 +1,14 @@
package session package focus
import ( import (
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/knowledge"
"antidrift/internal/store"
"antidrift/internal/tasks"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"keel/internal/ai"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"keel/internal/tasks"
"strings" "strings"
"time" "time"
) )
@@ -58,7 +58,7 @@ const (
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes // SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully. // RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach) { func (c *Mode) SetCoach(coach ai.Coach) {
c.mu.Lock() c.mu.Lock()
c.coach = coach c.coach = coach
c.mu.Unlock() c.mu.Unlock()
@@ -66,7 +66,7 @@ func (c *Controller) SetCoach(coach ai.Coach) {
// resetCoachLocked returns coach state to idle and invalidates any in-flight // resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu. // request. Caller holds mu.
func (c *Controller) resetCoachLocked() { func (c *Mode) resetCoachLocked() {
c.coachStatus = coachIdle c.coachStatus = coachIdle
c.coachProposal = nil c.coachProposal = nil
c.coachErr = "" c.coachErr = ""
@@ -75,47 +75,16 @@ func (c *Controller) resetCoachLocked() {
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps // SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent. // the planning tasks list absent.
func (c *Controller) SetTasks(p tasks.Provider) { func (c *Mode) SetTasks(p tasks.Provider) {
c.mu.Lock() c.mu.Lock()
c.tasksProvider = p c.tasksProvider = p
c.mu.Unlock() c.mu.Unlock()
} }
// runFetchAsync launches a generation-guarded background fetch. The caller has
// already captured its dependencies and (for the *Locked callers) holds c.mu;
// this method only spawns the goroutine, which re-acquires the lock itself.
//
// Closure contract:
// - fetch performs the I/O and runs with NO lock held; it must not touch c
// fields without taking c.mu itself.
// - stale runs under the re-acquired c.mu and returns true to DISCARD the
// result (e.g. a newer generation superseded this one).
// - apply runs under the re-acquired c.mu and records the result (persisting
// itself when the role requires it); it must NOT call c.notify — the helper
// owns the post-unlock notify, and notify self-locks, so calling it under
// c.mu would deadlock.
//
// On a non-stale completion the helper notifies after releasing the lock.
func (c *Controller) runFetchAsync(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fetch(ctx)
c.mu.Lock()
if stale() {
c.mu.Unlock()
return
}
apply()
c.mu.Unlock()
c.notify()
}()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider // startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or // is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu. // post-planning results, and notifies on completion. Caller holds mu.
func (c *Controller) startTasksFetchLocked() { func (c *Mode) startTasksFetchLocked() {
c.tasksList = nil c.tasksList = nil
if c.tasksProvider == nil { if c.tasksProvider == nil {
c.tasksStatus = tasksIdle c.tasksStatus = tasksIdle
@@ -127,7 +96,7 @@ func (c *Controller) startTasksFetchLocked() {
provider := c.tasksProvider provider := c.tasksProvider
var list []tasks.Task var list []tasks.Task
var err error var err error
c.runFetchAsync(tasksTimeout, c.async.Run(tasksTimeout,
func(ctx context.Context) { list, err = provider.Today(ctx) }, func(ctx context.Context) { list, err = provider.Today(ctx) },
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning }, func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
func() { func() {
@@ -143,7 +112,7 @@ func (c *Controller) startTasksFetchLocked() {
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source // SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded. // keeps the planning knowledge view absent and the coach ungrounded.
func (c *Controller) SetKnowledge(s knowledge.Source) { func (c *Mode) SetKnowledge(s knowledge.Source) {
c.mu.Lock() c.mu.Lock()
c.knowledgeSrc = s c.knowledgeSrc = s
c.mu.Unlock() c.mu.Unlock()
@@ -152,7 +121,7 @@ func (c *Controller) SetKnowledge(s knowledge.Source) {
// SetKnowledgePath selects an explicit profile path (session-only; not // SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the // persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default. // cached grounding update. An empty path resets to the adapter default.
func (c *Controller) SetKnowledgePath(path string) { func (c *Mode) SetKnowledgePath(path string) {
c.mu.Lock() c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path) c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning planning := c.runtimeState == domain.RuntimePlanning
@@ -169,7 +138,7 @@ func (c *Controller) SetKnowledgePath(path string) {
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or // Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached // post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu. // in knowledgeText for the coach to read. Caller holds mu.
func (c *Controller) startKnowledgeFetchLocked() { func (c *Mode) startKnowledgeFetchLocked() {
c.knowledgeText = "" c.knowledgeText = ""
c.knowledgeChars = 0 c.knowledgeChars = 0
if c.knowledgeSrc == nil { if c.knowledgeSrc == nil {
@@ -183,7 +152,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
path := c.knowledgePath path := c.knowledgePath
var prof knowledge.Profile var prof knowledge.Profile
var err error var err error
c.runFetchAsync(knowledgeTimeout, c.async.Run(knowledgeTimeout,
func(ctx context.Context) { prof, err = src.Load(ctx, path) }, func(ctx context.Context) { prof, err = src.Load(ctx, path) },
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning }, func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
func() { func() {
@@ -210,7 +179,7 @@ func (c *Controller) startKnowledgeFetchLocked() {
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and // SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward. // leaves the coach ungrounded by any carry-forward.
func (c *Controller) SetReviewer(r ai.Reviewer) { func (c *Mode) SetReviewer(r ai.Reviewer) {
c.mu.Lock() c.mu.Lock()
c.reviewer = r c.reviewer = r
c.mu.Unlock() c.mu.Unlock()
@@ -223,7 +192,7 @@ func (c *Controller) SetReviewer(r ai.Reviewer) {
// review (a later session's fetch) bumps the generation and discards this one. // review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer // The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu. // never leaves stale data from the previous session. Caller holds mu.
func (c *Controller) startReflectionFetchLocked() { func (c *Mode) startReflectionFetchLocked() {
c.reflectionRecap = "" c.reflectionRecap = ""
c.carryForward = "" c.carryForward = ""
if c.reviewer == nil { if c.reviewer == nil {
@@ -245,7 +214,7 @@ func (c *Controller) startReflectionFetchLocked() {
history := buildReflectionHistory(c.auditPath) history := buildReflectionHistory(c.auditPath)
var refl ai.Reflection var refl ai.Reflection
var err error var err error
c.runFetchAsync(reflectionTimeout, c.async.Run(reflectionTimeout,
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) }, func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
func() bool { return gen != c.reflectionGen }, func() bool { return gen != c.reflectionGen },
func() { func() {
@@ -267,7 +236,7 @@ func (c *Controller) startReflectionFetchLocked() {
// totals, and a top-N on-task list and top-N off-task list. The split fields are // totals, and a top-N on-task list and top-N off-task list. The split fields are
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still // populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
// set (End clears them, but enterReview runs before End). // set (End clears them, but enterReview runs before End).
func (c *Controller) buildReflectionFinishedLocked() string { func (c *Mode) buildReflectionFinishedLocked() string {
var na, sc string var na, sc string
if c.commitment != nil { if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
@@ -340,7 +309,7 @@ func buildReflectionHistory(auditPath string) string {
// composedGroundingLocked combines the standing profile (knowledge port) with // composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string // the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu. // the coach already accepts. Caller holds mu.
func (c *Controller) composedGroundingLocked() string { func (c *Mode) composedGroundingLocked() string {
g := c.knowledgeText g := c.knowledgeText
if c.carryForward != "" { if c.carryForward != "" {
if g != "" { if g != "" {
@@ -354,7 +323,7 @@ func (c *Controller) composedGroundingLocked() string {
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if // RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach // not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted. // state). The proposal is ephemeral and never persisted.
func (c *Controller) RequestCoach(intent string) error { func (c *Mode) RequestCoach(intent string) error {
c.mu.Lock() c.mu.Lock()
if c.runtimeState != domain.RuntimePlanning { if c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock() c.mu.Unlock()
@@ -380,7 +349,7 @@ func (c *Controller) RequestCoach(intent string) error {
var prop ai.Proposal var prop ai.Proposal
var err error var err error
c.runFetchAsync(coachTimeout, c.async.Run(coachTimeout,
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) }, func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning }, func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
func() { func() {
@@ -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 (
@@ -3,7 +3,7 @@ package statemachine
import ( import (
"testing" "testing"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) { func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
@@ -1,8 +1,8 @@
package session package focus
import ( import (
"antidrift/internal/evidence" "keel/internal/evidence"
"antidrift/internal/store" "keel/internal/store"
"time" "time"
) )
@@ -27,7 +27,7 @@ type EvidenceStats struct {
// applyEvent advances stats by one observation: it credits the prior segment to // applyEvent advances stats by one observation: it credits the prior segment to
// the prior bucket, counts a context switch on key change, and records the new // the prior bucket, counts a context switch on key change, and records the new
// current window. Used by both live tracking and crash replay. Caller holds mu. // current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) { func (c *Mode) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast { if c.stats.hasLast {
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt)) c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
} }
@@ -47,7 +47,7 @@ func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs // runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
// before the state transition). idle/pending route to unclassified: honest, never // before the state transition). idle/pending route to unclassified: honest, never
// falsely on-task. Caller holds mu. // falsely on-task. Caller holds mu.
func (c *Controller) creditLocked(k bucketKey, d time.Duration) { func (c *Mode) creditLocked(k bucketKey, d time.Duration) {
c.stats.Buckets[k] += d c.stats.Buckets[k] += d
switch c.driftStatus { switch c.driftStatus {
case driftOnTask: case driftOnTask:
@@ -60,7 +60,7 @@ func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
} }
// replayStats rebuilds in-memory stats from the raw session log after a crash. // replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Controller) replayStats(sessionID string) { func (c *Mode) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID) events, err := store.ReplaySession(c.sessionsDir, sessionID)
if err != nil || len(events) == 0 { if err != nil || len(events) == 0 {
c.stats = &EvidenceStats{ c.stats = &EvidenceStats{
@@ -1,7 +1,7 @@
package session package focus
import ( import (
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
"sort" "sort"
"time" "time"
) )
@@ -102,7 +102,7 @@ type State struct {
Drift *DriftView `json:"drift,omitempty"` Drift *DriftView `json:"drift,omitempty"`
} }
func (c *Controller) stateLocked() State { func (c *Mode) stateLocked() State {
st := State{RuntimeState: c.runtimeState} st := State{RuntimeState: c.runtimeState}
if c.commitment != nil { if c.commitment != nil {
view := &CommitmentView{ view := &CommitmentView{
@@ -125,6 +125,14 @@ func (c *Controller) stateLocked() State {
} }
} }
if c.runtimeState == domain.RuntimePlanning { if c.runtimeState == domain.RuntimePlanning {
// Surface the live window so planning can show the real class to copy into
// the allowed-apps field — exact class names are otherwise invisible, and a
// non-matching token silently disables the local on-task fast path.
st.Evidence = &EvidenceView{
Available: c.latestWindow.Health.Available,
Reason: c.latestWindow.Health.Reason,
Current: WindowView{Class: c.latestWindow.Class, Title: c.latestWindow.Title},
}
status := c.coachStatus status := c.coachStatus
if status == "" { if status == "" {
status = coachIdle status = coachIdle
+44
View File
@@ -0,0 +1,44 @@
// Package mode defines the contract the harness uses to host a single
// collect→brain→act unit. Focus and off-screen are implementations.
package mode
import (
"context"
"encoding/json"
"time"
"keel/internal/evidence"
)
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
// Kind identifies the mode ("focus", "offscreen").
Kind() string
// Command routes a surface-issued command into the mode. It returns an
// error for illegal/invalid commands; the web layer maps it to HTTP.
Command(ctx context.Context, name string, payload json.RawMessage) error
// View projects the mode's current state for surfaces (JSON-marshalable).
View() any
// Active reports whether the mode still holds the harness. When it returns
// false the harness releases the mode and goes idle.
Active() bool
}
// EvidenceConsumer is implemented by modes that read the window sensor stream.
type EvidenceConsumer interface {
OnWindow(evidence.WindowSnapshot)
}
// Expirer is implemented by modes with a server-authoritative deadline. Expire
// takes no context to match focus's existing Expire() error method, so focus
// satisfies this interface with zero changes to its tested surface.
type Expirer interface {
Deadline() time.Time
Expire() error
}
// Envelope is the surfacing wrapper: the active mode's kind plus its View().
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"`
}
+104
View File
@@ -0,0 +1,104 @@
// internal/mode/offscreen/collect.go
package offscreen
import (
"context"
"fmt"
"strings"
"time"
"keel/internal/frame"
)
const (
recentProposals = 5 // how many recent proposals to show
outcomeScan = 50 // how many outcome events to scan for correlation
recentWindow = 7 * 24 * time.Hour
)
// assembleBrief builds the off-screen prompt context: the shared frame brief
// (today's tasks + ~/owc goals and life-domains) plus off-screen's own recent
// proposal history. Best-effort: never panics, never errors, tolerates nil
// ports and missing files.
func (m *Mode) assembleBrief() string {
base := frame.Assemble(context.Background(), m.know, m.tasks)
hist := m.briefHistory()
if hist == "" {
return base
}
var b strings.Builder
b.WriteString(base)
if !strings.HasSuffix(base, "\n") {
b.WriteString("\n")
}
b.WriteString("\n## Recently proposed\n")
b.WriteString(hist)
return frame.Truncate(b.String(), frame.MaxBriefBytes)
}
// briefHistory renders recent proposals with their outcome, newest first and
// within recentWindow. Returns "" when there is no memory or no recent history.
func (m *Mode) briefHistory() string {
if m.mem == nil {
return ""
}
ctx := context.Background()
proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals)
if err != nil || len(proposals) == 0 {
return ""
}
taken := m.idSet(ctx, kindActionTaken)
dismissed := m.idSet(ctx, kindProposalDismissed)
now := m.now()
var b strings.Builder
for _, ev := range proposals {
if now.Sub(ev.At) > recentWindow {
continue
}
id, _ := ev.Data["proposal_id"].(string)
action, _ := ev.Data["next_action"].(string)
if action == "" {
continue
}
outcome := "unactioned"
if taken[id] {
outcome = "confirmed"
} else if dismissed[id] {
outcome = "dismissed"
}
fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At)))
}
return b.String()
}
// idSet reads recent events of a kind and returns their proposal_ids as a set.
func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool {
out := map[string]bool{}
evs, err := m.mem.Recent(ctx, kind, outcomeScan)
if err != nil {
return out
}
for _, e := range evs {
if id, _ := e.Data["proposal_id"].(string); id != "" {
out[id] = true
}
}
return out
}
// relativeAge renders a coarse human age for a duration.
func relativeAge(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
+235
View File
@@ -0,0 +1,235 @@
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
// off-screen action from the life-domain frame and files it on confirm. It is
// one-shot — after confirm or dismiss it goes inactive and the harness releases
// it.
package offscreen
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"sync"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/mode"
"keel/internal/tasks"
)
const proposeTimeout = 60 * time.Second
const (
statusPending = "pending"
statusProposed = "proposed"
statusError = "error"
statusDone = "done"
)
const (
kindProposalMade = "proposal_made"
kindActionTaken = "action_taken"
kindProposalDismissed = "proposal_dismissed"
)
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
type Mode struct {
mu sync.Mutex
async harness.Async
ai *ai.Service
tasks tasks.Provider
know knowledge.Source // may be nil; assembleBrief must guard
mem memory.Store // may be nil; record helpers guard
now func() time.Time // event timestamps; never nil after New
status string
gen int
proposal *ai.OffscreenProposal
errMsg string
done bool
proposalID string
}
var _ mode.Mode = (*Mode)(nil)
// New builds an off-screen mode from the shared services. It does not start the
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
func New(svc harness.Services) *Mode {
now := svc.Clock
if now == nil {
now = time.Now
}
m := &Mode{
ai: svc.AI,
tasks: svc.Tasks,
know: svc.Knowledge,
mem: svc.Memory,
now: now,
status: statusPending,
}
m.async = harness.NewAsync(&m.mu, svc.Notify)
return m
}
func (m *Mode) Kind() string { return "offscreen" }
// Active reports whether the mode still holds the harness. It goes false after
// confirm or dismiss.
func (m *Mode) Active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return !m.done
}
// Command routes a surface command. start (re)runs the brief; confirm files the
// proposed action and finishes; dismiss finishes without writing.
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
switch name {
case "start":
return m.start()
case "confirm":
return m.confirm(ctx)
case "dismiss":
m.mu.Lock()
id := m.proposalID
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindProposalDismissed,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
})
return nil
default:
return fmt.Errorf("offscreen: unknown command %q", name)
}
}
// start assembles the brief and kicks off the brain asynchronously. A new call
// bumps the generation so a stale in-flight result is discarded.
func (m *Mode) start() error {
m.mu.Lock()
m.gen++
gen := m.gen
m.status = statusPending
m.proposal = nil
m.errMsg = ""
m.mu.Unlock()
brief := m.assembleBrief()
var p ai.OffscreenProposal
var err error
m.async.Run(proposeTimeout,
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
func() bool { return gen != m.gen },
func() {
if err != nil {
m.status = statusError
m.errMsg = err.Error()
return
}
m.status = statusProposed
m.proposal = &p
id := newProposalID()
m.proposalID = id
m.recordAsync(memory.Event{
Kind: kindProposalMade,
At: m.now(),
Data: map[string]any{
"proposal_id": id,
"mode": "offscreen",
"next_action": p.NextAction,
"rationale": p.Rationale,
},
})
})
return nil
}
// confirm files the proposed action as a task and ends the mode. It errors if
// there is nothing to confirm yet.
func (m *Mode) confirm(ctx context.Context) error {
m.mu.Lock()
p := m.proposal
m.mu.Unlock()
if p == nil {
return errors.New("offscreen: nothing to confirm")
}
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
return err
}
m.mu.Lock()
id := m.proposalID
m.status = statusDone
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindActionTaken,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction},
})
return nil
}
// View projects the mode's state for surfaces. Always JSON-marshalable.
func (m *Mode) View() any {
m.mu.Lock()
defer m.mu.Unlock()
v := map[string]any{"status": m.status}
if m.proposal != nil {
v["next_action"] = m.proposal.NextAction
v["rationale"] = m.proposal.Rationale
}
if m.errMsg != "" {
v["error"] = m.errMsg
}
return v
}
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
func Factory(svc harness.Services) mode.Mode {
m := New(svc)
_ = m.start()
return m
}
// recordAsync persists an event off the mode's lock, best-effort. Used on the
// propose path, which runs under the mode mutex (async apply).
func (m *Mode) recordAsync(ev memory.Event) {
if m.mem == nil {
return
}
go func() {
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}()
}
// recordSync persists an event inline, best-effort. Used on confirm/dismiss,
// which already do synchronous work.
func (m *Mode) recordSync(ev memory.Event) {
if m.mem == nil {
return
}
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}
// newProposalID returns a short random hex id correlating a proposal with its
// outcome event.
func newProposalID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "proposal"
}
return hex.EncodeToString(b[:])
}
+248
View File
@@ -0,0 +1,248 @@
package offscreen
import (
"context"
"errors"
"strings"
"testing"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/memory"
"keel/internal/tasks"
)
// stubBackend is a local fake ai.Backend; ai's package-internal fake is not
// reachable from this package, so we define our own here.
type stubBackend struct {
out string
err error
}
func (b stubBackend) Name() string { return "stub" }
func (b stubBackend) Run(context.Context, string) (string, error) {
return b.out, b.err
}
type fakeTasks struct {
created []string
}
func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil }
func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error {
f.created = append(f.created, t.Title)
return nil
}
func newTestMode(t *testing.T, ft *fakeTasks, backend ai.Backend) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Clock: func() time.Time { return time.Unix(0, 0) },
Notify: func() {},
}
return New(svc)
}
// waitStatus polls the mode's View until status reaches want, or fails after 2s.
func waitStatus(t *testing.T, m *Mode, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
v, _ := m.View().(map[string]any)
if v != nil && v["status"] == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("status never reached %q (last view: %+v)", want, m.View())
}
func okBackend() stubBackend {
return stubBackend{out: `{"next_action":"call mum","rationale":"people goal"}`}
}
func TestConfirmFilesExactlyOneTask(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
if len(ft.created) != 1 {
t.Fatalf("created %d tasks, want 1", len(ft.created))
}
if ft.created[0] != "call mum" {
t.Fatalf("created task title = %q, want %q", ft.created[0], "call mum")
}
if m.Active() {
t.Fatal("mode should be done after confirm")
}
}
func TestDismissWritesNothing(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
if len(ft.created) != 0 {
t.Fatalf("dismiss created %d tasks, want 0", len(ft.created))
}
if m.Active() {
t.Fatal("mode should be done after dismiss")
}
}
func TestProposeErrorSurfacesInView(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, stubBackend{err: errors.New("boom")})
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusError)
v, _ := m.View().(map[string]any)
if v == nil || v["error"] == nil || v["error"] == "" {
t.Fatalf("expected error field in view, got %+v", v)
}
if !m.Active() {
t.Fatal("mode should stay active on error so the user can retry or dismiss")
}
}
func TestConfirmWithoutProposalErrors(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
// No start → no proposal.
if err := m.Command(context.Background(), "confirm", nil); err == nil {
t.Fatal("confirm without a proposal should error")
}
if len(ft.created) != 0 {
t.Fatalf("confirm without proposal created %d tasks, want 0", len(ft.created))
}
}
func TestUnknownCommandErrors(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend())
if err := m.Command(context.Background(), "bogus", nil); err == nil {
t.Fatal("unknown command should error")
}
}
// newTestModeMem is newTestMode with an injected in-memory Store.
func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Memory: mem,
Clock: func() time.Time { return time.Unix(1000, 0) },
Notify: func() {},
}
return New(svc)
}
// waitEvent polls the fake until an event of kind appears, or fails after 2s.
func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
for _, e := range f.Events() {
if e.Kind == kind {
return e
}
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events())
return memory.Event{}
}
func TestProposeRecordsProposalMade(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
ev := waitEvent(t, mem, "proposal_made")
if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" {
t.Fatalf("proposal_made data = %+v", ev.Data)
}
}
func TestConfirmRecordsActionTakenWithSameID(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
made := waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
taken := waitEvent(t, mem, "action_taken")
if taken.Data["proposal_id"] != made.Data["proposal_id"] {
t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"])
}
}
func TestDismissRecordsProposalDismissed(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
_ = waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
_ = waitEvent(t, mem, "proposal_dismissed")
}
func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) {
mem := memory.NewFake()
ctx := context.Background()
// at = the test clock (Unix 1000); confirmed walk, dismissed stretch.
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w", "next_action": "walk"}})
_ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s"}})
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
brief := m.assembleBrief()
if !strings.Contains(brief, "Recently proposed") {
t.Fatalf("brief missing history section:\n%s", brief)
}
if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") {
t.Fatalf("brief missing confirmed walk:\n%s", brief)
}
if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") {
t.Fatalf("brief missing dismissed stretch:\n%s", brief)
}
}
func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected
if strings.Contains(m.assembleBrief(), "Recently proposed") {
t.Fatal("history section should be absent without memory")
}
}
+45
View File
@@ -0,0 +1,45 @@
// internal/notify/notify.go
// Package notify is Keel's desktop-notification effector: a best-effort
// "interrupt the user" primitive behind a Notifier port. On Linux with
// notify-send installed it pops a toast; everywhere else it is a silent nop.
// Mirrors internal/enforce: a pure OS primitive, all policy lives in callers.
package notify
import (
"context"
"fmt"
"os/exec"
)
// Notifier shows a desktop notification. Best-effort: it returns an error for
// diagnostics, but callers never block on it and treat failure as "no toast."
type Notifier interface {
Notify(ctx context.Context, title, body string) error
}
// NewNotifier returns the notify-send adapter when that binary is on PATH, else
// a nop. This makes a missing binary (or a non-Linux host) degrade to silence
// rather than an error.
func NewNotifier() Notifier {
if path, err := exec.LookPath("notify-send"); err == nil {
return sendNotifier{bin: path}
}
return nopNotifier{}
}
// sendNotifier shells out to notify-send. The app name groups Keel's toasts.
type sendNotifier struct{ bin string }
func (s sendNotifier) Notify(ctx context.Context, title, body string) error {
cmd := exec.CommandContext(ctx, s.bin, "--app-name=Keel", title, body)
if err := cmd.Run(); err != nil {
return fmt.Errorf("notify: notify-send: %w", err)
}
return nil
}
// nopNotifier is the silent fallback.
type nopNotifier struct{}
func (nopNotifier) Notify(context.Context, string, string) error { return nil }
+22
View File
@@ -0,0 +1,22 @@
// internal/notify/notify_test.go
package notify
import (
"context"
"testing"
)
// nopNotifier must be returned when notify-send is absent, and must never error.
func TestNopNotifierIsSilentAndSafe(t *testing.T) {
n := nopNotifier{}
if err := n.Notify(context.Background(), "title", "body"); err != nil {
t.Fatalf("nop Notify must return nil, got %v", err)
}
}
// NewNotifier always returns a usable Notifier (never nil), whatever the host.
func TestNewNotifierNeverNil(t *testing.T) {
if NewNotifier() == nil {
t.Fatal("NewNotifier returned nil")
}
}
+51 -8
View File
@@ -1,5 +1,5 @@
// Package settings persists the daemon's user-editable configuration as a single // Package settings persists the daemon's user-editable configuration as a single
// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a // JSON file (~/.keel/settings.json), parallel to the store snapshot. It is a
// leaf type package: it imports nothing else in the app so any layer may depend // leaf type package: it imports nothing else in the app so any layer may depend
// on the Settings value without pulling in adapters. // on the Settings value without pulling in adapters.
package settings package settings
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
) )
// ErrInvalidBackend marks a settings value whose ai_backend is not a known // ErrInvalidBackend marks a settings value whose ai_backend is not a known
@@ -16,21 +17,46 @@ import (
// without importing the ai package. // without importing the ai package.
var ErrInvalidBackend = errors.New("settings: invalid ai backend") var ErrInvalidBackend = errors.New("settings: invalid ai backend")
// ErrInvalidAmbientMode marks a settings value whose ambient_mode is not a
// known mode. The applier returns it (wrapped) so the HTTP layer can map it to
// 400 without importing the ambient package.
var ErrInvalidAmbientMode = errors.New("settings: invalid ambient mode")
// Ambient mode dial values.
const (
AmbientOff = "off"
AmbientStatus = "status"
AmbientNotify = "notify"
)
// ValidAmbientMode reports whether m is a known ambient mode constant.
func ValidAmbientMode(m string) bool {
switch m {
case AmbientOff, AmbientStatus, AmbientNotify:
return true
}
return false
}
// Settings is the user-editable configuration. Field names mirror the env vars // Settings is the user-editable configuration. Field names mirror the env vars
// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE. // they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE, KEEL_AW_URL,
// KEEL_AMBIENT_MODE, KEEL_AMBIENT_CADENCE_SECS.
type Settings struct { type Settings struct {
AIBackend string `json:"ai_backend"` AIBackend string `json:"ai_backend"`
MarvinCmd string `json:"marvin_cmd"` MarvinCmd string `json:"marvin_cmd"`
KnowledgePath string `json:"knowledge_path"` KnowledgePath string `json:"knowledge_path"`
AWURL string `json:"aw_url"`
AmbientMode string `json:"ambient_mode"`
AmbientCadenceSecs int `json:"ambient_cadence_secs"`
} }
// DefaultPath returns ~/.antidrift/settings.json. // DefaultPath returns ~/.keel/settings.json.
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, ".antidrift", "settings.json"), nil return filepath.Join(home, ".keel", "settings.json"), nil
} }
// Load reads a settings file. A missing file is an error; callers treat that as // Load reads a settings file. A missing file is an error; callers treat that as
@@ -64,17 +90,34 @@ func Save(path string, s Settings) error {
return os.Rename(tmp, path) return os.Rename(tmp, path)
} }
// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying // SeedFromEnv builds a Settings from the KEEL_* env vars, applying
// the same defaults the daemon used before the settings file existed. An unset // the same defaults the daemon used before the settings file existed. An unset
// AI backend defaults to "claude" (matching ai.NewBackend("")). // AI backend defaults to "claude" (matching ai.NewBackend("")).
func SeedFromEnv() Settings { func SeedFromEnv() Settings {
backend := os.Getenv("ANTIDRIFT_AI_BACKEND") backend := os.Getenv("KEEL_AI_BACKEND")
if backend == "" { if backend == "" {
backend = "claude" backend = "claude"
} }
awURL := os.Getenv("KEEL_AW_URL")
if awURL == "" {
awURL = "http://localhost:5600"
}
ambientMode := os.Getenv("KEEL_AMBIENT_MODE")
if ambientMode == "" {
ambientMode = AmbientNotify
}
ambientCadenceSecs := 300
if v := os.Getenv("KEEL_AMBIENT_CADENCE_SECS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
ambientCadenceSecs = n
}
}
return Settings{ return Settings{
AIBackend: backend, AIBackend: backend,
MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"), MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"), KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
AWURL: awURL,
AmbientMode: ambientMode,
AmbientCadenceSecs: ambientCadenceSecs,
} }
} }
+60 -8
View File
@@ -8,7 +8,7 @@ import (
func TestSaveLoadRoundTrip(t *testing.T) { func TestSaveLoadRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json") path := filepath.Join(t.TempDir(), "settings.json")
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600"}
if err := Save(path, want); err != nil { if err := Save(path, want); err != nil {
t.Fatalf("save: %v", err) t.Fatalf("save: %v", err)
} }
@@ -29,20 +29,23 @@ func TestLoadMissingFileIsError(t *testing.T) {
} }
func TestSeedFromEnvReadsVars(t *testing.T) { func TestSeedFromEnvReadsVars(t *testing.T) {
t.Setenv("ANTIDRIFT_AI_BACKEND", "codex") t.Setenv("KEEL_AI_BACKEND", "codex")
t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am") t.Setenv("KEEL_MARVIN_CMD", "uv run am")
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md") t.Setenv("KEEL_KNOWLEDGE_FILE", "/tmp/k.md")
t.Setenv("KEEL_AW_URL", "http://aw.local:5600")
t.Setenv("KEEL_AMBIENT_MODE", "")
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
got := SeedFromEnv() got := SeedFromEnv()
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600", AmbientMode: AmbientNotify, AmbientCadenceSecs: 300}
if got != want { if got != want {
t.Errorf("SeedFromEnv = %+v, want %+v", got, want) t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
} }
} }
func TestSeedFromEnvDefaultsBackend(t *testing.T) { func TestSeedFromEnvDefaultsBackend(t *testing.T) {
t.Setenv("ANTIDRIFT_AI_BACKEND", "") t.Setenv("KEEL_AI_BACKEND", "")
t.Setenv("ANTIDRIFT_MARVIN_CMD", "") t.Setenv("KEEL_MARVIN_CMD", "")
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "") t.Setenv("KEEL_KNOWLEDGE_FILE", "")
got := SeedFromEnv() got := SeedFromEnv()
if got.AIBackend != "claude" { if got.AIBackend != "claude" {
t.Errorf("default backend = %q, want claude", got.AIBackend) t.Errorf("default backend = %q, want claude", got.AIBackend)
@@ -58,3 +61,52 @@ func TestSaveCreatesDir(t *testing.T) {
t.Fatalf("file not written: %v", err) t.Fatalf("file not written: %v", err)
} }
} }
func TestSeedFromEnvAWURL(t *testing.T) {
t.Setenv("KEEL_AW_URL", "http://aw.example:9999")
if got := SeedFromEnv().AWURL; got != "http://aw.example:9999" {
t.Fatalf("AWURL = %q", got)
}
}
func TestSeedFromEnvAWURLDefault(t *testing.T) {
t.Setenv("KEEL_AW_URL", "")
if got := SeedFromEnv().AWURL; got != "http://localhost:5600" {
t.Fatalf("default AWURL = %q", got)
}
}
func TestValidAmbientMode(t *testing.T) {
for _, m := range []string{AmbientOff, AmbientStatus, AmbientNotify} {
if !ValidAmbientMode(m) {
t.Fatalf("%q should be valid", m)
}
}
if ValidAmbientMode("loud") {
t.Fatal("loud should be invalid")
}
if ValidAmbientMode("") {
t.Fatal("empty should be invalid (callers default before validating)")
}
}
func TestSeedFromEnvAmbientDefaults(t *testing.T) {
t.Setenv("KEEL_AMBIENT_MODE", "")
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
s := SeedFromEnv()
if s.AmbientMode != AmbientNotify {
t.Fatalf("ambient_mode default = %q, want %q", s.AmbientMode, AmbientNotify)
}
if s.AmbientCadenceSecs != 300 {
t.Fatalf("ambient_cadence_secs default = %d, want 300", s.AmbientCadenceSecs)
}
}
func TestSeedFromEnvAmbientFromEnv(t *testing.T) {
t.Setenv("KEEL_AMBIENT_MODE", "status")
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "120")
s := SeedFromEnv()
if s.AmbientMode != "status" || s.AmbientCadenceSecs != 120 {
t.Fatalf("env not honored: mode=%q cadence=%d", s.AmbientMode, s.AmbientCadenceSecs)
}
}
+101 -23
View File
@@ -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
} }
+99 -26
View File
@@ -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)
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
// Snapshot is the persisted current state of the daemon. // Snapshot is the persisted current state of the daemon.
@@ -29,13 +29,13 @@ type Snapshot struct {
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"` EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
} }
// DefaultPath returns ~/.antidrift/state.json. // DefaultPath returns ~/.keel/state.json.
func DefaultPath() (string, error) { func DefaultPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, ".antidrift", "state.json"), nil return filepath.Join(home, ".keel", "state.json"), nil
} }
// Load reads a snapshot. A missing file yields a default Locked snapshot. // Load reads a snapshot. A missing file yields a default Locked snapshot.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"antidrift/internal/domain" "keel/internal/mode/focus/domain"
) )
func TestLoadMissingFileReturnsLocked(t *testing.T) { func TestLoadMissingFileReturnsLocked(t *testing.T) {
+15 -4
View File
@@ -31,7 +31,8 @@ func execRunner(ctx context.Context, name string, args ...string) ([]byte, error
// (`am --json`, no subcommand) and parses the open tasks due today or earlier. // (`am --json`, no subcommand) and parses the open tasks due today or earlier.
type Marvin struct { type Marvin struct {
cmd string cmd string
args []string base []string // configured prefix after cmd, without the "--json" sentinel
args []string // read-path argv: base + ["--json"]
run runner run runner
} }
@@ -43,9 +44,9 @@ func NewMarvin(command string) *Marvin {
if len(fields) == 0 { if len(fields) == 0 {
fields = []string{"am"} fields = []string{"am"}
} }
args := append([]string{}, fields[1:]...) base := append([]string{}, fields[1:]...)
args = append(args, "--json") args := append(append([]string{}, base...), "--json")
return &Marvin{cmd: fields[0], args: args, run: execRunner} return &Marvin{cmd: fields[0], base: base, args: args, run: execRunner}
} }
// Today returns the open tasks due today or earlier, or an error if the CLI // Today returns the open tasks due today or earlier, or an error if the CLI
@@ -58,6 +59,16 @@ func (m *Marvin) Today(ctx context.Context) ([]Task, error) {
return parse(out) return parse(out)
} }
// Create adds a new task via ampy's `add` subcommand, shelling out through the
// same configured prefix as the read path so the add lands in the same CRDT
// store the reads observe. Only the title is sent in v1. It returns the CLI
// error on failure.
func (m *Marvin) Create(ctx context.Context, t Task) error {
args := append(append([]string{}, m.base...), "add", t.Title, "--json")
_, err := m.run(ctx, m.cmd, args...)
return err
}
type rawTask struct { type rawTask struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
+33
View File
@@ -3,6 +3,7 @@ package tasks
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
) )
@@ -106,3 +107,35 @@ func TestTodayPropagatesError(t *testing.T) {
t.Fatal("want error from failing runner") t.Fatal("want error from failing runner")
} }
} }
func TestCreateInvokesMarvinAddPreservingCRDT(t *testing.T) {
m := NewMarvin("uv run am")
var gotName string
var gotArgs []string
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
gotName, gotArgs = name, args
return []byte(`{"id":"task-1"}`), nil
}
if err := m.Create(context.Background(), NewTask("buy groceries")); err != nil {
t.Fatalf("Create: %v", err)
}
// Same prefix the read path shells out through, so add goes through the
// same ampy entrypoint and preserves its CRDT bookkeeping.
if gotName != "uv" {
t.Fatalf("runner name = %q, want %q", gotName, "uv")
}
joined := strings.Join(gotArgs, " ")
if !strings.Contains(joined, "add") || !strings.Contains(joined, "buy groceries") {
t.Fatalf("args = %v, want marvin add with title", gotArgs)
}
}
func TestCreatePropagatesError(t *testing.T) {
m := NewMarvin("am")
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
return nil, errors.New("am add failed")
}
if err := m.Create(context.Background(), NewTask("buy groceries")); err == nil {
t.Fatal("want error from failing runner")
}
}
+5 -1
View File
@@ -13,7 +13,11 @@ type Task struct {
} }
// Provider answers "what should I be doing?" — the open tasks due today or // Provider answers "what should I be doing?" — the open tasks due today or
// earlier. // earlier — and lets callers add new tasks.
type Provider interface { type Provider interface {
Today(ctx context.Context) ([]Task, error) Today(ctx context.Context) ([]Task, error)
Create(ctx context.Context, t Task) error
} }
// NewTask builds a minimal task for creation (title only in v1).
func NewTask(title string) Task { return Task{Title: title} }
+15 -2
View File
@@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"antidrift/internal/settings" "keel/internal/settings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -45,6 +45,19 @@ func (s *Server) handlePostSettings(c *gin.Context) {
s.settingsMu.Lock() s.settingsMu.Lock()
apply := s.applyFn apply := s.applyFn
path := s.settingsPath path := s.settingsPath
// aw_url is not exposed in the settings form; preserve the persisted value
// so a UI save never blanks a configured AW URL.
if req.AWURL == "" {
req.AWURL = s.settings.AWURL
}
// Likewise preserve ambient fields when a caller omits them, so a partial
// POST never clobbers a configured ambient dial.
if req.AmbientMode == "" {
req.AmbientMode = s.settings.AmbientMode
}
if req.AmbientCadenceSecs <= 0 {
req.AmbientCadenceSecs = s.settings.AmbientCadenceSecs
}
s.settingsMu.Unlock() s.settingsMu.Unlock()
if apply == nil { if apply == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
@@ -81,7 +94,7 @@ type browseResponse struct {
// handleBrowse lists subdirectories plus .md files under dir, so the UI can build // handleBrowse lists subdirectories plus .md files under dir, so the UI can build
// a file picker that returns a real server-side path. Dotfiles are NOT hidden — // a file picker that returns a real server-side path. Dotfiles are NOT hidden —
// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail // the default profile lives under ~/.keel. Localhost-only daemon; no jail
// beyond OS permissions (same trust boundary as the rest of the UI). // beyond OS permissions (same trust boundary as the rest of the UI).
func (s *Server) handleBrowse(c *gin.Context) { func (s *Server) handleBrowse(c *gin.Context) {
dir := strings.TrimSpace(c.Query("dir")) dir := strings.TrimSpace(c.Query("dir"))
+67 -7
View File
@@ -6,9 +6,11 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"antidrift/internal/settings" "keel/internal/harness"
"keel/internal/settings"
) )
// fakeApplier records the last settings it was asked to apply and can be told to // fakeApplier records the last settings it was asked to apply and can be told to
@@ -29,7 +31,7 @@ func (f *fakeApplier) apply(s settings.Settings) error {
} }
func TestGetSettingsReturnsCurrent(t *testing.T) { func TestGetSettingsReturnsCurrent(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"} cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
fa := &fakeApplier{} fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
@@ -51,7 +53,7 @@ func TestGetSettingsReturnsCurrent(t *testing.T) {
} }
func TestPostSettingsValidAppliesAndSaves(t *testing.T) { func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json") path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{} fa := &fakeApplier{}
s.SetSettings(path, settings.Settings{}, fa.apply) s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -78,7 +80,7 @@ func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
} }
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) { func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json") path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{reject: true} fa := &fakeApplier{reject: true}
s.SetSettings(path, settings.Settings{}, fa.apply) s.SetSettings(path, settings.Settings{}, fa.apply)
@@ -97,7 +99,7 @@ func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
} }
func TestPostSettingsInvalidJSONIs400(t *testing.T) { func TestPostSettingsInvalidJSONIs400(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
fa := &fakeApplier{} fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
r := s.Router() r := s.Router()
@@ -111,6 +113,37 @@ func TestPostSettingsInvalidJSONIs400(t *testing.T) {
} }
} }
func TestPostSettingsPreservesAWURL(t *testing.T) {
s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{}
cur := settings.Settings{AIBackend: "claude", AWURL: "http://aw.remote:9999"}
s.SetSettings(path, cur, fa.apply)
r := s.Router()
// POST a body that omits aw_url (mirrors what the real UI form sends).
body := `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":""}`
w := post(t, r, "/settings", body)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
}
if fa.called != 1 {
t.Fatalf("applier called %d times, want 1", fa.called)
}
// (a) applyFn must have received the preserved AWURL.
if fa.last.AWURL != "http://aw.remote:9999" {
t.Errorf("applied AWURL = %q, want http://aw.remote:9999", fa.last.AWURL)
}
// (b) persisted file must also preserve the AWURL.
saved, err := settings.Load(path)
if err != nil {
t.Fatalf("settings file not loadable: %v", err)
}
if saved.AWURL != "http://aw.remote:9999" {
t.Errorf("saved AWURL = %q, want http://aw.remote:9999", saved.AWURL)
}
}
func TestBrowseListsDirsAndMarkdown(t *testing.T) { func TestBrowseListsDirsAndMarkdown(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
@@ -123,7 +156,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
s := newTestServer(t) s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router() r := s.Router()
@@ -179,7 +212,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
} }
func TestBrowseBadDirIs400(t *testing.T) { func TestBrowseBadDirIs400(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router() r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil) req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
@@ -189,3 +222,30 @@ func TestBrowseBadDirIs400(t *testing.T) {
t.Fatalf("status = %d, want 400", w.Code) t.Fatalf("status = %d, want 400", w.Code)
} }
} }
func TestPostSettingsPreservesBlankAmbientFields(t *testing.T) {
h := harness.New(harness.Services{}, map[string]harness.Factory{})
s := NewServer(h)
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
current := settings.Settings{AIBackend: "claude", AmbientMode: "status", AmbientCadenceSecs: 120}
s.SetSettings(path, current, func(settings.Settings) error { return nil })
// A POST that omits the ambient fields must not blank them.
body := `{"ai_backend":"claude"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.Router().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
saved, err := settings.Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if saved.AmbientMode != "status" || saved.AmbientCadenceSecs != 120 {
t.Fatalf("ambient fields not preserved: mode=%q cadence=%d", saved.AmbientMode, saved.AmbientCadenceSecs)
}
}
+35
View File
@@ -73,6 +73,12 @@ input {
border-radius: 8px; color: var(--ink); font: inherit; border-radius: 8px; color: var(--ink); font: inherit;
} }
input:focus { outline: 0; border-color: var(--accent); } input:focus { outline: 0; border-color: var(--accent); }
/* Checkboxes must opt out of the full-width text-input styling above, which
otherwise stretches the box across the row and shoves its label adrift. */
input[type="checkbox"] {
width: auto; flex: none; margin: 0; padding: 0; accent-color: var(--accent);
}
.enforce-toggle { justify-content: flex-start; }
.btn { .btn {
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px; margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
@@ -82,6 +88,10 @@ input:focus { outline: 0; border-color: var(--accent); }
.btn-ghost { background: var(--line); color: var(--ink); } .btn-ghost { background: var(--line); color: var(--ink); }
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; } .btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
/* Large tap targets for the launcher and off-screen primary actions. */
.btn-big { display: block; width: 100%; margin-top: 12px; padding: 14px 16px; font-size: 16px; }
.launcher .btn-big:first-of-type { margin-top: 4px; }
/* Drift/nudge actions live in the status band; lay them out below the text. */ /* Drift/nudge actions live in the status band; lay them out below the text. */
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; } .band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
.band-actions .btn { margin-top: 0; padding: 7px 12px; } .band-actions .btn { margin-top: 0; padding: 7px 12px; }
@@ -170,3 +180,28 @@ input:focus { outline: 0; border-color: var(--accent); }
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
} }
.browse-list button:hover { background: var(--line); color: var(--accent); } .browse-list button:hover { background: var(--line); color: var(--accent); }
/* Off-screen mode: pending spinner. */
.offscreen-pending { text-align: center; }
.spinner {
width: 28px; height: 28px; border-radius: 50%;
border: 3px solid var(--line); border-top-color: var(--accent);
animation: spin 0.8s linear infinite; margin: 4px auto 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
.ambient-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 8px 0;
padding: 10px 14px;
border-radius: 10px;
background: #3a2a00;
color: #ffd479;
border: 1px solid #6b4f00;
}
.ambient-banner .ambient-line { font-weight: 600; }
.ambient-banner .ambient-actions { display: flex; gap: 8px; flex-shrink: 0; }
+129 -12
View File
@@ -2,6 +2,7 @@ const app = document.getElementById('app');
const view = document.getElementById('view'); const view = document.getElementById('view');
let countdownTimer = null; let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows let renderedState = null; // which runtime_state the DOM currently shows
let renderedMode = null; // the active_mode the DOM currently shows
let dismissedNudge = null; // text of the nudge the user has dismissed let dismissedNudge = null; // text of the nudge the user has dismissed
function post(path, body) { function post(path, body) {
@@ -86,9 +87,9 @@ function updateActiveDrift(state) {
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button> <button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
<button id="enddrift" type="button" class="btn btn-ghost">End session</button> <button id="enddrift" type="button" class="btn btn-ghost">End session</button>
</div>`; </div>`;
document.getElementById('refocus').onclick = () => post('/refocus'); document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
document.getElementById('ontask').onclick = () => post('/ontask'); document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
document.getElementById('enddrift').onclick = () => post('/complete'); document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
return; return;
} }
@@ -120,6 +121,24 @@ function updateActiveDrift(state) {
<span class="status-meta">on task · ${switches} switches</span>`; <span class="status-meta">on task · ${switches} switches</span>`;
} }
// updatePlanningWindowHint shows the live foreground window class so the user
// can copy the exact token into the allowed-apps field — class names are
// otherwise invisible, and a non-matching token silently disables on-task
// matching. Clicking the chip appends the class if not already listed.
function updatePlanningWindowHint(state) {
const el = document.getElementById('appHint');
if (!el) return;
const cls = state.evidence && state.evidence.current && state.evidence.current.class;
if (!cls) { el.innerHTML = ''; return; }
el.innerHTML = `current window: <button type="button" class="link" id="addClass">${esc(cls)}</button>`;
document.getElementById('addClass').onclick = () => {
const apps = document.getElementById('apps');
if (!apps) return;
const have = apps.value.split(',').map(s => s.trim()).filter(Boolean);
if (!have.includes(cls)) { have.push(cls); apps.value = have.join(', '); }
};
}
function updatePlanningCoach(coach) { function updatePlanningCoach(coach) {
const statusEl = document.getElementById('coachStatus'); const statusEl = document.getElementById('coachStatus');
if (!statusEl) return; if (!statusEl) return;
@@ -205,7 +224,7 @@ function updatePlanningReflection(refl) {
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`; el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
} }
function render(state) { function renderFocus(state) {
setStateAttr(state); setStateAttr(state);
const rs = state.runtime_state; const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') { if (rs === 'planning' && renderedState === 'planning') {
@@ -213,6 +232,7 @@ function render(state) {
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge); updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection); updatePlanningReflection(state.reflection);
updatePlanningWindowHint(state);
return; return;
} }
if (rs === 'active' && renderedState === 'active') { if (rs === 'active' && renderedState === 'active') {
@@ -229,7 +249,7 @@ function render(state) {
<p class="meta">No active commitment.</p> <p class="meta">No active commitment.</p>
<button id="plan" class="btn btn-primary">Start planning</button> <button id="plan" class="btn btn-primary">Start planning</button>
</div>`; </div>`;
document.getElementById('plan').onclick = () => post('/planning'); document.getElementById('plan').onclick = () => post('/mode/focus/start');
} else if (rs === 'planning') { } else if (rs === 'planning') {
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div> view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
@@ -248,6 +268,7 @@ function render(state) {
<label>Minutes</label><input id="mins" type="number" min="1" value="25"> <label>Minutes</label><input id="mins" type="number" min="1" value="25">
<label>Allowed apps (comma-separated window classes)</label> <label>Allowed apps (comma-separated window classes)</label>
<input id="apps" placeholder="e.g. code, firefox"> <input id="apps" placeholder="e.g. code, firefox">
<div id="appHint" class="hint"></div>
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label> <label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
<p class="hint">Minimize off-task windows when you drift.</p> <p class="hint">Minimize off-task windows when you drift.</p>
<button id="start" class="btn btn-primary" disabled>Start commitment</button> <button id="start" class="btn btn-primary" disabled>Start commitment</button>
@@ -256,7 +277,7 @@ function render(state) {
mins = document.getElementById('mins'), start = document.getElementById('start'); mins = document.getElementById('mins'), start = document.getElementById('start');
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); }; const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
[na, sc, mins].forEach(el => el.oninput = check); [na, sc, mins].forEach(el => el.oninput = check);
start.onclick = () => post('/commitment', { start.onclick = () => post('/mode/command/commitment', {
next_action: na.value.trim(), next_action: na.value.trim(),
success_condition: sc.value.trim(), success_condition: sc.value.trim(),
timebox_secs: Math.round(+mins.value * 60), timebox_secs: Math.round(+mins.value * 60),
@@ -266,12 +287,13 @@ function render(state) {
}); });
document.getElementById('sharpen').onclick = () => { document.getElementById('sharpen').onclick = () => {
const intent = document.getElementById('intent').value.trim(); const intent = document.getElementById('intent').value.trim();
if (intent) post('/coach', { intent }); if (intent) post('/mode/command/coach', { intent });
}; };
updatePlanningCoach(state.coach); updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge); updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection); updatePlanningReflection(state.reflection);
updatePlanningWindowHint(state);
} else if (rs === 'active') { } else if (rs === 'active') {
const c = state.commitment || {}; const c = state.commitment || {};
@@ -283,7 +305,7 @@ function render(state) {
</div> </div>
${evidenceBlock(state.evidence)} ${evidenceBlock(state.evidence)}
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`; <div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
document.getElementById('done').onclick = () => post('/complete'); document.getElementById('done').onclick = () => post('/mode/command/complete');
const t = document.getElementById('t'); const t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); }; const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick(); tick();
@@ -301,16 +323,99 @@ function render(state) {
${reflectionBlock(state.reflection)} ${reflectionBlock(state.reflection)}
${reviewSummary(state.evidence)} ${reviewSummary(state.evidence)}
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`; <div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
document.getElementById('end').onclick = () => post('/end'); document.getElementById('end').onclick = () => post('/mode/command/end');
} else { } else {
view.textContent = rs; view.textContent = rs;
} }
} }
// renderLauncher shows the idle screen with mode-start buttons.
function renderLauncher() {
app.dataset.state = 'locked';
view.innerHTML = `<div class="band statusband"><span class="pill">Keel</span></div>
<div class="band launcher">
<p class="meta">No active mode.</p>
<button id="startFocus" class="btn btn-primary btn-big">Start focus</button>
<button id="startOffscreen" class="btn btn-ghost btn-big">Off-screen</button>
</div>`;
document.getElementById('startFocus').onclick = () => post('/mode/focus/start');
document.getElementById('startOffscreen').onclick = () => post('/mode/offscreen/start');
}
// renderOffscreen shows the off-screen mode card. Basic but functional; Task 22
// polishes the phone-first styling.
function renderOffscreen(m) {
app.dataset.state = 'review';
const status = m.status || 'pending';
let body;
if (status === 'proposed') {
body = `<div class="band">
<div class="action">${esc(m.next_action || '')}</div>
<p class="meta">${esc(m.rationale || '')}</p>
</div>
<div class="band band-actions">
<button id="osDo" class="btn btn-primary btn-big">Do it</button>
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
</div>`;
} else if (status === 'error') {
body = `<div class="band">
<p class="meta">${esc(m.error || 'Could not propose an action.')}</p>
</div>
<div class="band band-actions">
<button id="osRetry" class="btn btn-primary btn-big">Retry</button>
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
</div>`;
} else { // pending / done
body = `<div class="band offscreen-pending">
<div class="spinner" aria-hidden="true"></div>
<p class="meta">Finding a worthwhile off-screen action</p>
</div>`;
}
view.innerHTML = `<div class="band statusband"><span class="pill">Off-screen</span></div>${body}`;
const doBtn = document.getElementById('osDo');
if (doBtn) doBtn.onclick = () => post('/mode/command/confirm');
const disBtn = document.getElementById('osDismiss');
if (disBtn) disBtn.onclick = () => post('/mode/command/dismiss');
const retryBtn = document.getElementById('osRetry');
if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start');
}
// updateAmbient shows the always-on drift banner whenever the server reports an
// ambient coaching line, regardless of the active mode. It clears when empty.
function updateAmbient(env) {
const el = document.getElementById('ambientBanner');
const line = (env && env.ambient) || '';
if (!line) { el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
el.innerHTML = `<span class="ambient-line">⚠ ${esc(line)}</span>
<span class="ambient-actions">
<button type="button" class="btn btn-ghost" id="ambientSnooze">Snooze 1h</button>
<button type="button" class="btn btn-primary" id="ambientFocus">Start focus</button>
</span>`;
document.getElementById('ambientSnooze').onclick = () => post('/ambient/snooze');
document.getElementById('ambientFocus').onclick = () => post('/mode/focus/start');
}
// route dispatches the state envelope to the active mode's renderer. On a mode
// switch it resets the focus in-place trackers so stale DOM never leaks across.
function route(env) {
const am = (env && env.active_mode) || '';
updateAmbient(env);
if (am !== renderedMode) {
renderedMode = am;
renderedState = null;
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
}
if (am === '') return renderLauncher();
if (am === 'focus') return renderFocus(env.mode || {});
if (am === 'offscreen') return renderOffscreen(env.mode || {});
view.textContent = am; // unknown mode: degrade visibly
}
const es = new EventSource('/events'); const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data)); es.onmessage = (e) => route(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; }; es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; };
// ---- Settings overlay ---- // ---- Settings overlay ----
function openSettings() { function openSettings() {
@@ -328,10 +433,18 @@ function openSettings() {
<input id="setMarvin" placeholder="uv run am"> <input id="setMarvin" placeholder="uv run am">
<label>Knowledge profile path</label> <label>Knowledge profile path</label>
<div class="path-row"> <div class="path-row">
<input id="setKnow" placeholder="~/.antidrift/knowledge.md"> <input id="setKnow" placeholder="~/.keel/knowledge.md">
<button type="button" class="btn btn-ghost" id="setBrowse">Browse</button> <button type="button" class="btn btn-ghost" id="setBrowse">Browse</button>
</div> </div>
<div id="browsePane" class="browse" hidden></div> <div id="browsePane" class="browse" hidden></div>
<label>Ambient coach</label>
<select id="setAmbient">
<option value="notify">notify (status + toast)</option>
<option value="status">status only</option>
<option value="off">off</option>
</select>
<label>Ambient check every (seconds)</label>
<input id="setAmbientCadence" type="number" min="30" placeholder="300">
<div id="setError" class="set-error"></div> <div id="setError" class="set-error"></div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button> <button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
@@ -341,6 +454,8 @@ function openSettings() {
document.getElementById('setBackend').value = s.ai_backend || 'claude'; document.getElementById('setBackend').value = s.ai_backend || 'claude';
document.getElementById('setMarvin').value = s.marvin_cmd || ''; document.getElementById('setMarvin').value = s.marvin_cmd || '';
document.getElementById('setKnow').value = s.knowledge_path || ''; document.getElementById('setKnow').value = s.knowledge_path || '';
document.getElementById('setAmbient').value = s.ambient_mode || 'notify';
document.getElementById('setAmbientCadence').value = s.ambient_cadence_secs || 300;
ov.hidden = false; ov.hidden = false;
document.getElementById('setCancel').onclick = closeSettings; document.getElementById('setCancel').onclick = closeSettings;
document.getElementById('setSave').onclick = saveSettings; document.getElementById('setSave').onclick = saveSettings;
@@ -366,6 +481,8 @@ function saveSettings() {
ai_backend: document.getElementById('setBackend').value, ai_backend: document.getElementById('setBackend').value,
marvin_cmd: document.getElementById('setMarvin').value.trim(), marvin_cmd: document.getElementById('setMarvin').value.trim(),
knowledge_path: document.getElementById('setKnow').value.trim(), knowledge_path: document.getElementById('setKnow').value.trim(),
ambient_mode: document.getElementById('setAmbient').value,
ambient_cadence_secs: parseInt(document.getElementById('setAmbientCadence').value, 10) || 300,
}; };
post('/settings', body).then(r => { post('/settings', body).then(r => {
if (r.ok) { closeSettings(); return; } if (r.ok) { closeSettings(); return; }
+3 -2
View File
@@ -3,14 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>AntiDrift</title> <title>Keel</title>
<link rel="icon" href="/favicon.ico" sizes="any"> <link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/app.css"> <link rel="stylesheet" href="/app.css">
</head> </head>
<body> <body>
<main id="app"> <main id="app">
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings"></button></h1> <h1>Keel <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings"></button></h1>
<div id="ambientBanner" class="ambient-banner" hidden></div>
<div class="card" id="view">connecting…</div> <div class="card" id="view">connecting…</div>
<div id="settingsOverlay" class="overlay" hidden></div> <div id="settingsOverlay" class="overlay" hidden></div>
</main> </main>
+98 -88
View File
@@ -7,15 +7,16 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"sync" "sync"
"time" "time"
"antidrift/internal/domain" "keel/internal/harness"
"antidrift/internal/session" "keel/internal/mode"
"antidrift/internal/settings" "keel/internal/mode/focus/statemachine"
"antidrift/internal/statemachine" "keel/internal/settings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -23,11 +24,14 @@ import (
//go:embed static/* //go:embed static/*
var staticFS embed.FS var staticFS embed.FS
// Server wires the session controller to HTTP and SSE. // Server wires the harness to HTTP and SSE.
type Server struct { type Server struct {
ctrl *session.Controller h *harness.Harness
bcast *Broadcaster bcast *Broadcaster
ambientLine func() string
ambientSnooze func(time.Duration)
mu sync.Mutex mu sync.Mutex
timer *time.Timer timer *time.Timer
@@ -38,16 +42,20 @@ type Server struct {
applyMu sync.Mutex // serializes POST /settings apply+save applyMu sync.Mutex // serializes POST /settings apply+save
} }
func NewServer(ctrl *session.Controller) *Server { // NewServer wires the harness to the SSE broadcaster: every harness change
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()} // (mode start, command, async role completion, expiry) fans out the current
ctrl.SetOnChange(s.broadcast) // state envelope to subscribers. The callback is registered additively, so the
// status-file writer can register its own listener alongside this one.
func NewServer(h *harness.Harness) *Server {
s := &Server{h: h, bcast: NewBroadcaster()}
h.AddOnChange(s.broadcast)
return s return s
} }
func (s *Server) Router() *gin.Engine { func (s *Server) Router() *gin.Engine {
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) r.Use(gin.Recovery())
// antidriftd binds localhost only and sits behind no proxy, so trust no // keeld binds localhost only and sits behind no proxy, so trust no
// forwarded-IP headers. This also silences gin's trust-all-proxies warning. // forwarded-IP headers. This also silences gin's trust-all-proxies warning.
_ = r.SetTrustedProxies(nil) _ = r.SetTrustedProxies(nil)
@@ -68,102 +76,103 @@ func (s *Server) Router() *gin.Engine {
c.FileFromFS("/favicon.png", http.FS(sub)) c.FileFromFS("/favicon.png", http.FS(sub))
}) })
r.GET("/events", s.handleEvents) r.GET("/events", s.handleEvents)
r.POST("/planning", s.handlePlanning) r.POST("/mode/:kind/start", s.handleStart)
r.POST("/coach", s.handleCoach) r.POST("/mode/command/:name", s.handleCommand)
r.POST("/commitment", s.handleCommitment)
r.POST("/complete", s.handleComplete)
r.POST("/end", s.handleEnd)
r.POST("/refocus", s.handleRefocus)
r.POST("/ontask", s.handleOnTask)
r.GET("/settings", s.handleGetSettings) r.GET("/settings", s.handleGetSettings)
r.POST("/settings", s.handlePostSettings) r.POST("/settings", s.handlePostSettings)
r.GET("/fs/browse", s.handleBrowse) r.GET("/fs/browse", s.handleBrowse)
r.POST("/ambient/snooze", s.handleAmbientSnooze)
return r return r
} }
func (s *Server) stateJSON() string { func (s *Server) stateJSON() string {
data, _ := json.Marshal(s.ctrl.State()) line := ""
if s.ambientLine != nil {
line = s.ambientLine()
}
payload := struct {
mode.Envelope
Ambient string `json:"ambient"`
}{Envelope: s.h.State(), Ambient: line}
data, _ := json.Marshal(payload)
return string(data) return string(data)
} }
// SetAmbient injects the ambient sentinel's line accessor and snooze action,
// wired by main. Called once at startup before serving.
func (s *Server) SetAmbient(line func() string, snooze func(time.Duration)) {
s.ambientLine = line
s.ambientSnooze = snooze
}
// Broadcast pushes the current state envelope to SSE subscribers. Exposed so the
// ambient sentinel's change signal can refresh the UI alongside harness changes.
func (s *Server) Broadcast() { s.broadcast() }
// handleAmbientSnooze mutes the ambient coach for an hour.
func (s *Server) handleAmbientSnooze(c *gin.Context) {
if s.ambientSnooze != nil {
s.ambientSnooze(time.Hour)
}
c.Status(http.StatusNoContent)
}
func (s *Server) broadcast() { func (s *Server) broadcast() {
s.bcast.Publish(s.stateJSON()) s.bcast.Publish(s.stateJSON())
} }
// respond maps a controller error to an HTTP status, otherwise broadcasts and // respond maps a harness/mode error to an HTTP status, otherwise returns the
// returns the new state. // new state envelope. Broadcasting is handled solely by the harness onChange
// listeners registered in NewServer; callers must not broadcast here.
func (s *Server) respond(c *gin.Context, err error) { func (s *Server) respond(c *gin.Context, err error) {
if err != nil { if err != nil {
var illegal statemachine.IllegalTransitionError var illegal statemachine.IllegalTransitionError
if errors.As(err, &illegal) { switch {
case errors.As(err, &illegal):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return case errors.Is(err, harness.ErrBusy):
} c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, harness.ErrIdle):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, harness.ErrUnknownMode):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return return
} }
s.broadcast()
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
} }
func (s *Server) handlePlanning(c *gin.Context) { // handleStart activates a mode by kind. Starting focus arms the expiry timer in
s.cancelExpiry() // case the mode is restored or transitions straight into a deadline-bearing
s.respond(c, s.ctrl.EnterPlanning()) // state; the arm is a no-op when there is no deadline yet.
} func (s *Server) handleStart(c *gin.Context) {
kind := c.Param("kind")
type coachRequest struct { err := s.h.Start(kind)
Intent string `json:"intent"` if err == nil && kind == "focus" {
}
func (s *Server) handleCoach(c *gin.Context) {
var req coachRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
s.respond(c, s.ctrl.RequestCoach(req.Intent))
}
type commitmentRequest struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
Enforce bool `json:"enforce"`
}
func (s *Server) handleCommitment(c *gin.Context) {
var req commitmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
level := domain.EnforcementWarn
if req.Enforce {
level = domain.EnforcementBlock
}
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
if err == nil {
s.armExpiry() s.armExpiry()
} }
s.respond(c, err) s.respond(c, err)
} }
func (s *Server) handleComplete(c *gin.Context) { // handleCommand routes a surface command to the active mode and manages the
// expiry timer for the focus lifecycle: a commitment arms it, completing or
// ending cancels it. The raw body is forwarded as the command payload; modes
// decode and validate it themselves.
func (s *Server) handleCommand(c *gin.Context) {
name := c.Param("name")
body, _ := io.ReadAll(c.Request.Body)
err := s.h.Command(c.Request.Context(), name, body)
switch name {
case "commitment":
if err == nil {
s.armExpiry()
}
case "complete", "end":
s.cancelExpiry() s.cancelExpiry()
s.respond(c, s.ctrl.Complete()) }
} s.respond(c, err)
func (s *Server) handleEnd(c *gin.Context) {
s.respond(c, s.ctrl.End())
}
func (s *Server) handleRefocus(c *gin.Context) {
s.respond(c, s.ctrl.Refocus())
}
func (s *Server) handleOnTask(c *gin.Context) {
s.respond(c, s.ctrl.OnTask())
} }
func (s *Server) handleEvents(c *gin.Context) { func (s *Server) handleEvents(c *gin.Context) {
@@ -196,9 +205,12 @@ func (s *Server) handleEvents(c *gin.Context) {
} }
} }
// armExpiry schedules an Active -> Review transition at the commitment deadline. // armExpiry schedules an Active -> Review transition at the active mode's
// deadline. A zero deadline (no deadline-bearing mode) is a no-op. On firing,
// Expire notifies harness listeners, which broadcasts the new state via the
// callback registered in NewServer.
func (s *Server) armExpiry() { func (s *Server) armExpiry() {
deadline := s.ctrl.Deadline() deadline := s.h.Deadline()
if deadline.IsZero() { if deadline.IsZero() {
return return
} }
@@ -212,9 +224,7 @@ func (s *Server) armExpiry() {
d = 0 d = 0
} }
s.timer = time.AfterFunc(d, func() { s.timer = time.AfterFunc(d, func() {
if err := s.ctrl.Expire(); err == nil { _ = s.h.Expire()
s.broadcast()
}
}) })
} }
@@ -227,17 +237,17 @@ func (s *Server) cancelExpiry() {
} }
} }
// Init re-arms or expires a restored Active session at startup. // Init re-arms or expires a restored deadline-bearing session at startup. With
// no active mode the deadline is zero, so this is a no-op until a session is
// adopted.
func (s *Server) Init() { func (s *Server) Init() {
st := s.ctrl.State() deadline := s.h.Deadline()
if st.RuntimeState != "active" { if deadline.IsZero() {
return return
} }
if s.ctrl.Deadline().After(time.Now()) { if deadline.After(time.Now()) {
s.armExpiry() s.armExpiry()
return return
} }
if err := s.ctrl.Expire(); err == nil { _ = s.h.Expire()
s.broadcast()
}
} }
+218 -88
View File
@@ -9,26 +9,46 @@ import (
"testing" "testing"
"time" "time"
"antidrift/internal/ai" "keel/internal/ai"
"antidrift/internal/domain" "keel/internal/evidence"
"antidrift/internal/evidence" "keel/internal/harness"
"antidrift/internal/knowledge" "keel/internal/knowledge"
"antidrift/internal/session" "keel/internal/mode"
"antidrift/internal/settings" "keel/internal/mode/focus"
"antidrift/internal/tasks" "keel/internal/mode/focus/domain"
"keel/internal/settings"
"keel/internal/tasks"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func newTestServer(t *testing.T) *Server { // newTestServer builds a harness around a freshly-constructed focus mode and
// returns the server plus the mode so tests can inject per-role stubs (the old
// controller's Set* API lives on focus.Mode) and assert on the typed focus
// state. The mode is registered behind a factory that wires the harness change
// notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on
// every focus change. The mode starts Locked; tests drive it through the
// command routes (e.g. POST /mode/command/planning), mirroring the original
// suite which POSTed /planning first.
func newTestServer(t *testing.T) (*Server, *focus.Mode) {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
ctrl, err := session.New(path) m, err := focus.New(path)
if err != nil { if err != nil {
t.Fatalf("controller: %v", err) t.Fatalf("focus.New: %v", err)
} }
return NewServer(ctrl) h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{
"focus": func(svc harness.Services) mode.Mode {
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m
},
})
s := NewServer(h)
if err := h.Start("focus"); err != nil {
t.Fatalf("harness.Start(focus): %v", err)
}
return s, m
} }
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder { func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
@@ -40,53 +60,73 @@ func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRec
return w return w
} }
// fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for
// the real-factory parity test without reaching any model.
type fakeBackend struct{}
func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil }
func (fakeBackend) Name() string { return "fake" }
func TestPlanningThenCommitmentReachesActive(t *testing.T) { func TestPlanningThenCommitmentReachesActive(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code) t.Fatalf("/planning code %d", w.Code)
} }
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
} }
if s.ctrl.State().RuntimeState != domain.RuntimeActive { if m.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState) t.Fatalf("expected Active, got %s", m.State().RuntimeState)
} }
} }
func TestCommitmentRejectsInvalidInput(t *testing.T) { func TestCommitmentRejectsInvalidInput(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}` body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
w := post(t, r, "/commitment", body) w := post(t, r, "/mode/command/commitment", body)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
func TestIllegalTransitionReturns409(t *testing.T) { func TestIllegalTransitionReturns409(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
// /complete from Locked is illegal. // /complete from Locked is illegal.
w := post(t, r, "/complete", "") w := post(t, r, "/mode/command/complete", "")
if w.Code != http.StatusConflict { if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code) t.Fatalf("expected 409, got %d", w.Code)
} }
} }
// TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with
// the focus state nested under "mode" and the kind under "active_mode".
func TestEnvelopeShape(t *testing.T) {
s, _ := newTestServer(t)
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("payload missing active_mode: %s", js)
}
if !strings.Contains(js, `"mode"`) {
t.Fatalf("payload missing nested mode object: %s", js)
}
}
func TestActiveStatePayloadCarriesEvidence(t *testing.T) { func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
} }
// A focus update should appear in the serialized state. // A focus update should appear in the serialized state envelope.
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}}) m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
js := s.stateJSON() js := s.stateJSON()
if !strings.Contains(js, `"evidence"`) { if !strings.Contains(js, `"evidence"`) {
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
} }
func TestLockedStateHasNullEvidence(t *testing.T) { func TestLockedStateHasNullEvidence(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
// End any active session to return to Locked; a freshly-started focus mode is
// already Locked (Start does not auto-plan in this harness wiring).
if m.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState)
}
js := s.stateJSON() js := s.stateJSON()
if !strings.Contains(js, `"evidence":null`) { if !strings.Contains(js, `"evidence":null`) {
t.Fatalf("locked payload should have null evidence: %s", js) t.Fatalf("locked payload should have null evidence: %s", js)
@@ -115,60 +160,60 @@ func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Prop
} }
func TestCoachRouteHappyPath(t *testing.T) { func TestCoachRouteHappyPath(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
t.Fatalf("/coach code %d body %s", w.Code, w.Body.String()) t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" { if cv := m.State().Coach; cv != nil && cv.Status == "ready" {
return return
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
} }
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach) t.Fatalf("coach never reached ready: %+v", m.State().Coach)
} }
func TestCoachRouteOutsidePlanning(t *testing.T) { func TestCoachRouteOutsidePlanning(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetCoach(stubCoach{}) m.SetCoach(stubCoach{})
r := s.Router() r := s.Router()
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest { if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
func TestCoachRouteInvalidJSON(t *testing.T) { func TestCoachRouteInvalidJSON(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest { if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
func TestCoachRouteUnavailableDegrades(t *testing.T) { func TestCoachRouteUnavailableDegrades(t *testing.T) {
s := newTestServer(t) // no SetCoach s, m := newTestServer(t) // no SetCoach
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
t.Fatalf("unavailable coach should still 200, got %d", w.Code) t.Fatalf("unavailable coach should still 200, got %d", w.Code)
} }
if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" { if cv := m.State().Coach; cv == nil || cv.Status != "error" {
t.Fatalf("want error status, got %+v", cv) t.Fatalf("want error status, got %+v", cv)
} }
} }
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) { func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest { if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest {
t.Fatalf("refocus outside Active: want 400, got %d", w.Code) t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
} }
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest { if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest {
t.Fatalf("ontask outside Active: want 400, got %d", w.Code) t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
} }
} }
@@ -180,22 +225,22 @@ func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles [
} }
func TestActiveStatePayloadCarriesNudge(t *testing.T) { func TestActiveStatePayloadCarriesNudge(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"}) m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}` body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
} }
// Two distinct on-task titles trip the nudge (history >= 2; first call has no // Two distinct on-task titles trip the nudge (history >= 2; first call has no
// debounce to wait on). // debounce to wait on).
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}}) m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}}) m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" { if d := m.State().Drift; d != nil && d.Nudge != "" {
break break
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
} }
func TestServesStaticAssets(t *testing.T) { func TestServesStaticAssets(t *testing.T) {
s := newTestServer(t) s, _ := newTestServer(t)
r := s.Router() r := s.Router()
cases := []struct { cases := []struct {
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return s.list, nil return s.list, nil
} }
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
func TestPlanningStatePayloadCarriesTasks(t *testing.T) { func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}}) m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
r := s.Router() r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code) t.Fatalf("/planning code %d", w.Code)
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" { if tv := m.State().Tasks; tv != nil && tv.Status == "ready" {
break break
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
} }
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) { func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}` body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String()) t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
} }
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
t.Fatalf("commitment did not carry allowed classes: %#v", got) t.Fatalf("commitment did not carry allowed classes: %#v", got)
} }
// Now Active: overrides succeed. // Now Active: overrides succeed.
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK {
t.Fatalf("ontask while Active: want 200, got %d", w.Code) t.Fatalf("ontask while Active: want 200, got %d", w.Code)
} }
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK {
t.Fatalf("refocus while Active: want 200, got %d", w.Code) t.Fatalf("refocus while Active: want 200, got %d", w.Code)
} }
} }
@@ -298,15 +345,15 @@ func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, e
} }
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) { func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}}) m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
r := s.Router() r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code) t.Fatalf("/planning code %d", w.Code)
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" { if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" {
break break
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -329,20 +376,20 @@ func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.
} }
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) { func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}}) m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
r := s.Router() r := s.Router()
_ = post(t, r, "/planning", "") _ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}` body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
} }
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code) t.Fatalf("/complete code %d", w.Code)
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" { if rv := m.State().Reflection; rv != nil && rv.Status == "ready" {
break break
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
t.Fatalf("review payload missing recap: %s", js) t.Fatalf("review payload missing recap: %s", js)
} }
// End -> Locked -> Planning: the carry-forward should surface on planning. // End -> Locked: the harness releases the focus mode and goes idle. Re-start
if w := post(t, r, "/end", ""); w.Code != http.StatusOK { // the mode (re-adopting the same in-memory focus, carry-forward intact) and
// re-enter Planning; the carry-forward should surface there.
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code) t.Fatalf("/end code %d", w.Code)
} }
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code) t.Fatalf("/planning code %d", w.Code)
} }
js2 := s.stateJSON() js2 := s.stateJSON()
@@ -365,6 +417,34 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
} }
} }
func TestStateJSONIncludesAmbient(t *testing.T) {
h := harness.New(harness.Services{}, map[string]harness.Factory{})
s := NewServer(h)
s.SetAmbient(func() string { return "deep in YouTube" }, func(time.Duration) {})
js := s.stateJSON()
if !strings.Contains(js, `"ambient":"deep in YouTube"`) {
t.Fatalf("state JSON missing ambient field: %s", js)
}
}
func TestAmbientSnoozeRoute(t *testing.T) {
h := harness.New(harness.Services{}, map[string]harness.Factory{})
s := NewServer(h)
var snoozed time.Duration
s.SetAmbient(func() string { return "" }, func(d time.Duration) { snoozed = d })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/ambient/snooze", nil)
s.Router().ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("snooze status = %d, want 204", w.Code)
}
if snoozed != time.Hour {
t.Fatalf("snooze duration = %v, want 1h", snoozed)
}
}
type stubJudge struct{ verdict ai.Verdict } type stubJudge struct{ verdict ai.Verdict }
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) { func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
@@ -372,19 +452,19 @@ func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title stri
} }
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) { func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
r := s.Router() r := s.Router()
s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) m.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code) t.Fatalf("/planning code %d", w.Code)
} }
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}` body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
} }
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}}) m.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -397,15 +477,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
} }
func TestKnowledgePathSelectionReloads(t *testing.T) { func TestKnowledgePathSelectionReloads(t *testing.T) {
s := newTestServer(t) s, m := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
settings.Settings{}, func(ss settings.Settings) error { settings.Settings{}, func(ss settings.Settings) error {
s.ctrl.SetKnowledgePath(ss.KnowledgePath) m.SetKnowledgePath(ss.KnowledgePath)
return nil return nil
}) })
r := s.Router() r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code) t.Fatalf("/planning code %d", w.Code)
} }
if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK { if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK {
@@ -413,10 +493,60 @@ func TestKnowledgePathSelectionReloads(t *testing.T) {
} }
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" { if kv := m.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
return return
} }
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
} }
t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge) t.Fatalf("path selection did not reload: %+v", m.State().Knowledge)
}
// TestRealFocusFactoryParity drives the REAL focus.Factory through the harness
// (Start -> commitment -> complete -> end), proving the generalized routes work
// end-to-end against a focus mode the harness itself constructs from Services —
// not a pre-built mode injected by the test. focus.Factory auto-enters Planning
// on Start, so no explicit planning command is needed.
func TestRealFocusFactoryParity(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := harness.Services{
AI: ai.NewService(fakeBackend{}),
Clock: time.Now,
Dir: t.TempDir(),
}
h := harness.New(svc, map[string]harness.Factory{"focus": focus.Factory})
s := NewServer(h)
r := s.Router()
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
// Start auto-plans; the envelope should report focus active and Planning.
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("envelope missing active_mode focus: %s", js)
}
if !strings.Contains(js, `"runtime_state":"planning"`) {
t.Fatalf("expected planning after start: %s", js)
}
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"active"`) {
t.Fatalf("expected active after commitment: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"review"`) {
t.Fatalf("expected review after complete: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
// End drives focus to Locked; the harness releases it and goes idle.
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after end ActiveMode = %q, want idle", got)
}
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Package winapi is the Windows Win32 binding layer for AntiDrift's OS ports. // Package winapi is the Windows Win32 binding layer for Keel's OS ports.
// The syscall-bound code lives in windows-tagged files; this untagged file // 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. // holds the pure path logic so it builds and is tested on every platform.
package winapi package winapi
-1102
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "antidrift"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.86"
fs2 = "0.4"
hex = "0.4.3"
ratatui = "0.27.0"
regex = "1.10.5"
shellexpand = "3.1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
sha2 = "0.10.8"
uuid = { version = "1", features = ["v7"] }
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "windef"] }
-6
View File
@@ -1,6 +0,0 @@
[Desktop Entry]
Name=AntiDrift
Exec=antidrift
Terminal=false
Type=Application
StartupNotify=false
-36
View File
@@ -1,36 +0,0 @@
pub const APP_TITLE: &str = "AntiDrift";
pub const DEFAULT_DURATION: &str = "25";
pub const DURATION_TITLE: &str = "Duration";
#[allow(dead_code)]
pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence";
pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! ";
pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl";
pub const INTENTION_TITLE: &str = "Intention";
pub const PAUSED: &str = "paused";
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
pub const PROVIDE_INTENTION: &str = "Provide intention! ";
pub const PROVIDE_VALID_DURATION: &str = "Provide valid duration in minutes! ";
pub const RATE_TITLES: &str = "Press 1, 2, 3 to rate titles!";
pub const READY_TO_START: &str = "Ready to start next session.";
pub const SESSION_IN_PROGRESS: &str = "Session In-Progress";
pub const SESSION_PAUSED: &str = "Session is paused. Unpause with 'p'.";
pub const SESSION_STATS_TITLE: &str = "Session Stats";
pub const STATUS_FILE: &str = "~/.antidrift_status";
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
pub const STATUS_TITLE: &str = "Status";
pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session";
pub const STATUS_LOCKED: &str = "🔒 antidrift locked";
pub const STATUS_PLANNING: &str = "🧭 antidrift planning";
pub const STATUS_PAUSED: &str = "⏸ antidrift paused";
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
pub const STATUS_QUIT: &str = "antidrift shutting down";
pub const STATUS_TRANSITION: &str = "↔ antidrift transition";
pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition";
pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes";
pub const TRANSITION_REASON_TITLE: &str = "Transition Reason";
pub const TRANSITION_RETURN_TITLE: &str = "Return Target";
pub const VIOLATION_TITLE: &str = "Violation";
pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason";
pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue.";
pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! ";
pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! ";
-156
View File
@@ -1,156 +0,0 @@
use crate::domain::AllowedContext;
pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_domain(candidate);
if candidate.is_empty() {
return false;
}
context.domains.iter().any(|allowed| {
let allowed = normalize_domain(allowed);
!allowed.is_empty()
&& (candidate == allowed
|| candidate
.strip_suffix(&allowed)
.is_some_and(|prefix| prefix.ends_with('.')))
})
}
pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_casefolded(candidate);
if candidate.is_empty() {
return false;
}
context
.window_classes
.iter()
.any(|allowed| normalize_casefolded(allowed) == candidate)
}
pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_casefolded(candidate);
if candidate.is_empty() {
return false;
}
context.window_title_substrings.iter().any(|allowed| {
let allowed = normalize_casefolded(allowed);
!allowed.is_empty() && candidate.contains(&allowed)
})
}
pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = executable_basename(candidate);
if candidate.is_empty() {
return false;
}
context
.commands
.iter()
.any(|allowed| executable_basename(allowed) == candidate)
}
fn normalize_domain(value: &str) -> String {
value.trim().trim_end_matches('.').to_lowercase()
}
fn normalize_casefolded(value: &str) -> String {
value.trim().to_lowercase()
}
fn executable_basename(command: &str) -> String {
let executable = command.split_whitespace().next().unwrap_or_default();
executable
.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::AllowedContext;
#[test]
fn domains_match_exact_or_subdomain() {
let context = AllowedContext {
domains: vec!["github.com".to_string()],
..AllowedContext::default()
};
assert!(domain_allowed(&context, "github.com"));
assert!(domain_allowed(&context, "docs.github.com"));
assert!(!domain_allowed(&context, "evilgithub.com"));
}
#[test]
fn window_classes_match_case_insensitively_after_normalization() {
let context = AllowedContext {
window_classes: vec!["code".to_string()],
..AllowedContext::default()
};
assert!(window_class_allowed(&context, "Code"));
assert!(!window_class_allowed(&context, "firefox"));
}
#[test]
fn titles_match_by_substring() {
let context = AllowedContext {
window_title_substrings: vec!["antidrift".to_string()],
..AllowedContext::default()
};
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
assert!(!window_title_allowed(&context, "random video"));
}
#[test]
fn domains_ignore_whitespace_case_and_trailing_dots() {
let context = AllowedContext {
domains: vec![" GitHub.COM. ".to_string()],
..AllowedContext::default()
};
assert!(domain_allowed(&context, " DOCS.GITHUB.COM. "));
}
#[test]
fn window_class_matching_trims_allowed_and_candidate_values() {
let context = AllowedContext {
window_classes: vec![" code ".to_string()],
..AllowedContext::default()
};
assert!(window_class_allowed(&context, " Code "));
}
#[test]
fn title_matching_trims_allowed_substrings() {
let context = AllowedContext {
window_title_substrings: vec![" antidrift ".to_string()],
..AllowedContext::default()
};
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
}
#[test]
fn commands_match_executable_basename() {
let context = AllowedContext {
commands: vec!["cargo".to_string()],
..AllowedContext::default()
};
assert!(command_allowed(
&context,
"/home/felixm/.cargo/bin/cargo test"
));
assert!(command_allowed(&context, "cargo"));
assert!(!command_allowed(&context, "/usr/bin/git status"));
}
}
-515
View File
@@ -1,515 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub const POLICY_SCHEMA_VERSION: u16 = 1;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitmentSource {
Manual,
Planner,
Recurring,
Recovery,
Template,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitmentState {
Draft,
Active,
Paused,
Completed,
Abandoned,
Violated,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeState {
Locked,
Planning,
Active,
Transition,
Review,
AdminOverride,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnforcementLevel {
Observe,
Warn,
Block,
Locked,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceHealth {
Available,
Degraded(String),
Unavailable(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AllowedContext {
pub window_classes: Vec<String>,
pub window_title_substrings: Vec<String>,
pub domains: Vec<String>,
pub repos: Vec<String>,
pub commands: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Commitment {
pub id: String,
pub created_at_unix_secs: u64,
pub source: CommitmentSource,
pub project_id: Option<String>,
pub template_id: Option<String>,
pub next_action: String,
pub success_condition: String,
pub timebox_secs: u64,
pub transition_policy_id: Option<String>,
pub state: CommitmentState,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicySnapshot {
pub id: String,
pub commitment_id: String,
pub schema_version: u16,
pub created_at_unix_secs: u64,
pub runtime_state: RuntimeState,
pub enforcement_level: EnforcementLevel,
pub allowed_context: AllowedContext,
pub required_monitors: Vec<String>,
pub violation_actions: Vec<String>,
pub expires_at_unix_secs: Option<u64>,
pub generated_by_agent_version: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommitmentValidationError {
MissingId,
MissingNextAction,
MissingSuccessCondition,
MissingTimebox,
}
impl fmt::Display for CommitmentValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommitmentValidationError::MissingId => write!(f, "commitment id is required"),
CommitmentValidationError::MissingNextAction => write!(f, "next action is required"),
CommitmentValidationError::MissingSuccessCondition => {
write!(f, "success condition is required")
}
CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"),
}
}
}
impl std::error::Error for CommitmentValidationError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicySnapshotValidationError {
WrongSchemaVersion,
MissingId,
MissingCommitmentId,
MissingGeneratedByAgentVersion,
}
impl fmt::Display for PolicySnapshotValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicySnapshotValidationError::WrongSchemaVersion => {
write!(f, "policy schema version is unsupported")
}
PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"),
PolicySnapshotValidationError::MissingCommitmentId => {
write!(f, "commitment id is required")
}
PolicySnapshotValidationError::MissingGeneratedByAgentVersion => {
write!(f, "generated agent version is required")
}
}
}
}
impl std::error::Error for PolicySnapshotValidationError {}
impl Commitment {
pub fn new_manual(
next_action: impl Into<String>,
success_condition: impl Into<String>,
timebox: Duration,
) -> Result<Self, CommitmentValidationError> {
let next_action = next_action.into().trim().to_string();
let success_condition = success_condition.into().trim().to_string();
if timebox.as_secs() == 0 {
return Err(CommitmentValidationError::MissingTimebox);
}
let created_at_unix_secs = unix_secs_now();
let commitment = Self {
id: unique_id("commitment"),
created_at_unix_secs,
source: CommitmentSource::Manual,
project_id: None,
template_id: None,
next_action,
success_condition,
timebox_secs: timebox.as_secs(),
transition_policy_id: None,
state: CommitmentState::Draft,
};
commitment.validate()?;
Ok(commitment)
}
pub fn validate(&self) -> Result<(), CommitmentValidationError> {
if self.id.trim().is_empty() {
return Err(CommitmentValidationError::MissingId);
}
if self.next_action.trim().is_empty() {
return Err(CommitmentValidationError::MissingNextAction);
}
if self.success_condition.trim().is_empty() {
return Err(CommitmentValidationError::MissingSuccessCondition);
}
if self.timebox_secs == 0 {
return Err(CommitmentValidationError::MissingTimebox);
}
Ok(())
}
}
impl PolicySnapshot {
pub fn for_runtime(
commitment_id: String,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
) -> Self {
Self::try_for_runtime(
commitment_id,
runtime_state,
enforcement_level,
allowed_context,
)
.expect("runtime policy snapshot requires a non-empty commitment id")
}
pub fn try_for_runtime(
commitment_id: String,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
) -> Result<Self, PolicySnapshotValidationError> {
let commitment_id = commitment_id.trim().to_string();
if commitment_id.is_empty() {
return Err(PolicySnapshotValidationError::MissingCommitmentId);
}
let created_at_unix_secs = unix_secs_now();
let policy = Self {
id: unique_id("policy"),
commitment_id,
schema_version: POLICY_SCHEMA_VERSION,
created_at_unix_secs,
runtime_state,
enforcement_level,
allowed_context,
required_monitors: Vec::new(),
violation_actions: Vec::new(),
expires_at_unix_secs: None,
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
};
policy.validate()?;
Ok(policy)
}
pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> {
if self.schema_version != POLICY_SCHEMA_VERSION {
return Err(PolicySnapshotValidationError::WrongSchemaVersion);
}
if self.id.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingId);
}
if self.commitment_id.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingCommitmentId);
}
if self.generated_by_agent_version.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion);
}
Ok(())
}
}
pub fn unix_secs_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unique_id(prefix: &str) -> String {
format!("{prefix}-{}", Uuid::now_v7())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn commitment_requires_concrete_action_and_success_condition() {
let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500))
.expect_err("empty next action must be rejected");
assert_eq!(err, CommitmentValidationError::MissingNextAction);
let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500))
.expect_err("empty success condition must be rejected");
assert_eq!(err, CommitmentValidationError::MissingSuccessCondition);
}
#[test]
fn commitment_requires_nonzero_timebox() {
let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO)
.expect_err("zero timebox must be rejected");
assert_eq!(err, CommitmentValidationError::MissingTimebox);
}
#[test]
fn policy_snapshot_carries_runtime_contract() {
let commitment = Commitment::new_manual(
"Implement domain types",
"domain tests pass",
Duration::from_secs(1500),
)
.unwrap();
let policy = PolicySnapshot::for_runtime(
commitment.id.clone(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
assert_eq!(policy.commitment_id, commitment.id);
assert_eq!(policy.runtime_state, RuntimeState::Active);
assert_eq!(policy.enforcement_level, EnforcementLevel::Warn);
assert_eq!(policy.schema_version, 1);
}
#[test]
fn commitment_ids_are_unique_for_rapid_consecutive_construction() {
let first = Commitment::new_manual(
"Implement first action",
"first tests pass",
Duration::from_secs(1500),
)
.unwrap();
let second = Commitment::new_manual(
"Implement second action",
"second tests pass",
Duration::from_secs(1500),
)
.unwrap();
assert_ne!(first.id, second.id);
}
#[test]
fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() {
let first = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
let second = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
assert_ne!(first.id, second.id);
}
#[test]
fn commitment_serializes_enum_contract_as_snake_case() {
let commitment = Commitment::new_manual(
"Implement domain contracts",
"domain tests pass",
Duration::from_secs(1500),
)
.unwrap();
let json = serde_json::to_string(&commitment).unwrap();
assert!(json.contains(r#""source":"manual""#));
assert!(json.contains(r#""state":"draft""#));
}
#[test]
fn policy_snapshot_serializes_enum_contract_as_snake_case() {
let policy = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
let json = serde_json::to_string(&policy).unwrap();
assert!(json.contains(r#""runtime_state":"active""#));
assert!(json.contains(r#""enforcement_level":"warn""#));
}
#[test]
fn commitment_validate_rejects_invalid_public_field_values() {
let mut commitment =
Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500))
.unwrap();
commitment.id = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingId)
);
commitment.id = "commitment-1".to_string();
commitment.next_action = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingNextAction)
);
commitment.next_action = "Refactor state".to_string();
commitment.success_condition = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingSuccessCondition)
);
commitment.success_condition = "tests pass".to_string();
commitment.timebox_secs = 0;
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingTimebox)
);
}
#[test]
fn commitment_validate_rejects_invalid_deserialized_values() {
let json = r#"{
"id": "",
"created_at_unix_secs": 1,
"source": "manual",
"project_id": null,
"template_id": null,
"next_action": "Refactor state",
"success_condition": "tests pass",
"timebox_secs": 1500,
"transition_policy_id": null,
"state": "draft"
}"#;
let commitment: Commitment = serde_json::from_str(json).unwrap();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingId)
);
}
#[test]
fn validation_errors_implement_std_error() {
fn assert_std_error<E: std::error::Error>() {}
assert_std_error::<CommitmentValidationError>();
assert_std_error::<PolicySnapshotValidationError>();
}
#[test]
fn policy_snapshot_try_for_runtime_rejects_empty_commitment_id() {
let err = PolicySnapshot::try_for_runtime(
" ".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
)
.expect_err("empty commitment id must be rejected");
assert_eq!(err, PolicySnapshotValidationError::MissingCommitmentId);
}
#[test]
fn policy_snapshot_validate_rejects_invalid_public_field_values() {
let mut policy = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
policy.schema_version = POLICY_SCHEMA_VERSION + 1;
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::WrongSchemaVersion)
);
policy.schema_version = POLICY_SCHEMA_VERSION;
policy.id = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingId)
);
policy.id = "policy-1".to_string();
policy.commitment_id = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingCommitmentId)
);
policy.commitment_id = "commitment-1".to_string();
policy.generated_by_agent_version = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion)
);
}
#[test]
fn policy_snapshot_validate_rejects_invalid_deserialized_values() {
let json = r#"{
"id": "",
"commitment_id": "commitment-1",
"schema_version": 1,
"created_at_unix_secs": 1,
"runtime_state": "active",
"enforcement_level": "warn",
"allowed_context": {
"window_classes": [],
"window_title_substrings": [],
"domains": [],
"repos": [],
"commands": []
},
"required_monitors": [],
"violation_actions": [],
"expires_at_unix_secs": null,
"generated_by_agent_version": "0.1.0"
}"#;
let policy: PolicySnapshot = serde_json::from_str(json).unwrap();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingId)
);
}
}
-583
View File
@@ -1,583 +0,0 @@
use crate::domain::{unix_secs_now, RuntimeState, POLICY_SCHEMA_VERSION};
use anyhow::{anyhow, Context, Result};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventType {
CommitmentCreated,
RuntimeTransition,
CommitmentTransition,
PolicyApplied,
EvidenceObserved,
ViolationObserved,
TransitionStarted,
ReviewCompleted,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EventRecord {
pub schema_version: u16,
pub sequence: u64,
pub timestamp_unix_secs: u64,
pub event_type: EventType,
pub commitment_id: Option<String>,
pub runtime_state: RuntimeState,
pub payload_json: Value,
pub previous_hash: Option<String>,
pub hash: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PendingEventRecord {
pub event_type: EventType,
pub runtime_state: RuntimeState,
pub commitment_id: Option<String>,
pub payload_json: Value,
}
#[derive(Debug)]
pub struct EventLog {
path: PathBuf,
next_sequence: u64,
previous_hash: Option<String>,
needs_parent_sync_after_append: bool,
}
impl EventLog {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
create_parent_dirs(&path)?;
let (mut file, created) = open_log_file(&path)?;
file.lock_shared()
.with_context(|| format!("lock event log for read {}", path.display()))?;
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &path)?;
Ok(Self {
path,
next_sequence,
previous_hash,
needs_parent_sync_after_append: created,
})
}
pub fn append(
&mut self,
event_type: EventType,
runtime_state: RuntimeState,
commitment_id: Option<String>,
payload_json: Value,
) -> Result<EventRecord> {
let mut records = self.append_batch([PendingEventRecord {
event_type,
runtime_state,
commitment_id,
payload_json,
}])?;
records
.pop()
.ok_or_else(|| anyhow!("single event append produced no record"))
}
pub fn append_batch(
&mut self,
events: impl IntoIterator<Item = PendingEventRecord>,
) -> Result<Vec<EventRecord>> {
let events: Vec<PendingEventRecord> = events.into_iter().collect();
if events.is_empty() {
return Ok(Vec::new());
}
create_parent_dirs(&self.path)?;
let existed = self
.path
.try_exists()
.with_context(|| format!("check whether event log exists {}", self.path.display()))?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&self.path)
.with_context(|| format!("open event log for append {}", self.path.display()))?;
file.lock_exclusive()
.with_context(|| format!("lock event log {}", self.path.display()))?;
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &self.path)?;
let mut records = Vec::with_capacity(events.len());
let mut sequence = next_sequence;
let mut record_previous_hash = previous_hash;
for event in events {
let mut record = EventRecord {
schema_version: POLICY_SCHEMA_VERSION,
sequence,
timestamp_unix_secs: unix_secs_now(),
event_type: event.event_type,
commitment_id: event.commitment_id,
runtime_state: event.runtime_state,
payload_json: event.payload_json,
previous_hash: record_previous_hash,
hash: String::new(),
};
record.hash = record_hash(&record)?;
record_previous_hash = Some(record.hash.clone());
sequence += 1;
records.push(record);
}
let mut bytes = Vec::new();
for record in &records {
serde_json::to_writer(&mut bytes, record).with_context(|| {
format!("serialize event log record to {}", self.path.display())
})?;
bytes.push(b'\n');
}
file.write_all(&bytes).with_context(|| {
format!(
"write {} event log records to {}",
records.len(),
self.path.display()
)
})?;
file.flush()
.with_context(|| format!("flush event log {}", self.path.display()))?;
file.sync_data()
.with_context(|| format!("sync event log {}", self.path.display()))?;
if !existed || self.needs_parent_sync_after_append {
sync_parent_dir(&self.path)?;
self.needs_parent_sync_after_append = false;
}
self.next_sequence = sequence;
self.previous_hash = record_previous_hash;
Ok(records)
}
pub fn records(&self) -> Result<Vec<EventRecord>> {
let mut file = OpenOptions::new()
.read(true)
.open(&self.path)
.with_context(|| format!("open event log for read {}", self.path.display()))?;
file.lock_shared()
.with_context(|| format!("lock event log for read {}", self.path.display()))?;
load_records_locked(&mut file, &self.path)
}
}
fn create_parent_dirs(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create parent directories for event log {}", path.display())
})?;
}
Ok(())
}
fn open_log_file(path: &Path) -> Result<(File, bool)> {
let existed = path
.try_exists()
.with_context(|| format!("check whether event log exists {}", path.display()))?;
let file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(path)
.with_context(|| format!("open event log {}", path.display()))?;
Ok((file, !existed))
}
fn load_tail_locked(file: &mut File, path: &Path) -> Result<(u64, Option<String>)> {
let records = load_records_locked(file, path)?;
let next_sequence = records.last().map_or(1, |record| record.sequence + 1);
let previous_hash = records.last().map(|record| record.hash.clone());
Ok((next_sequence, previous_hash))
}
fn load_records_locked(file: &mut File, path: &Path) -> Result<Vec<EventRecord>> {
let mut next_sequence = 1;
let mut previous_hash = None;
let mut records = Vec::new();
file.seek(SeekFrom::Start(0))
.with_context(|| format!("seek event log {}", path.display()))?;
for (line_index, line) in BufReader::new(file).lines().enumerate() {
let line_number = line_index + 1;
let line = line.with_context(|| {
format!("read event log line {line_number} from {}", path.display())
})?;
if line.trim().is_empty() {
continue;
}
let record: EventRecord = serde_json::from_str(&line).with_context(|| {
format!("parse event log line {line_number} from {}", path.display())
})?;
validate_record(&record, next_sequence, previous_hash.as_deref())
.map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?;
next_sequence += 1;
previous_hash = Some(record.hash.clone());
records.push(record);
}
Ok(records)
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
let dir = File::open(parent)
.with_context(|| format!("open parent directory for event log {}", path.display()))?;
dir.sync_all().with_context(|| {
format!(
"sync parent directory {} for event log {}",
parent.display(),
path.display()
)
})?;
}
Ok(())
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> Result<()> {
// Directory fsync is not exposed portably by std on all supported platforms.
// Linux and other Unix targets take the durable path above.
Ok(())
}
fn validate_record(
record: &EventRecord,
expected_sequence: u64,
expected_previous_hash: Option<&str>,
) -> Result<()> {
if record.schema_version != POLICY_SCHEMA_VERSION {
return Err(anyhow!(
"unsupported schema version at sequence {}: expected {}, found {}",
record.sequence,
POLICY_SCHEMA_VERSION,
record.schema_version
));
}
if record.sequence != expected_sequence {
return Err(anyhow!(
"sequence mismatch: expected {}, found {}",
expected_sequence,
record.sequence
));
}
if record.previous_hash.as_deref() != expected_previous_hash {
return Err(anyhow!(
"previous hash mismatch at sequence {}",
record.sequence
));
}
let expected_hash = record_hash(record)?;
if record.hash != expected_hash {
return Err(anyhow!("hash mismatch at sequence {}", record.sequence));
}
Ok(())
}
fn record_hash(record: &EventRecord) -> Result<String> {
let mut hashable = record.clone();
hashable.hash.clear();
let bytes = serde_json::to_vec(&hashable).context("serialize event log record for hash")?;
Ok(hex::encode(Sha256::digest(bytes)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::RuntimeState;
use serde_json::json;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_log_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"antidrift-event-log-{name}-{}-{nonce}.jsonl",
std::process::id()
))
}
#[test]
fn appends_hash_chained_jsonl_records() {
let path = temp_log_path("append");
let mut log = EventLog::open(&path).unwrap();
let first = log
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
Some("commitment-1".to_string()),
json!({"from": "active", "to": "transition"}),
)
.unwrap();
let second = log
.append(
EventType::PolicyApplied,
RuntimeState::Active,
Some("commitment-1".to_string()),
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(first.sequence, 1);
assert_eq!(second.sequence, 2);
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
assert_ne!(first.hash, second.hash);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
fs::remove_file(path).unwrap();
}
#[test]
fn batch_appends_hash_chained_adjacent_records() {
let path = temp_log_path("batch");
let mut log = EventLog::open(&path).unwrap();
let records = log
.append_batch([
PendingEventRecord {
event_type: EventType::CommitmentCreated,
runtime_state: RuntimeState::Active,
commitment_id: Some("commitment-1".to_string()),
payload_json: json!({"commitment_id": "commitment-1"}),
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: RuntimeState::Active,
commitment_id: Some("commitment-1".to_string()),
payload_json: json!({"policy_id": "policy-1"}),
},
])
.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].sequence, 1);
assert_eq!(records[1].sequence, 2);
assert_eq!(
records[1].previous_hash.as_deref(),
Some(records[0].hash.as_str())
);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn opening_existing_log_validates_chain_and_continues_sequence() {
let path = temp_log_path("reopen");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
}
let mut reopened = EventLog::open(&path).unwrap();
let next = reopened
.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(next.sequence, 2);
fs::remove_file(path).unwrap();
}
#[test]
fn opening_missing_log_creates_reopenable_file() {
let path = temp_log_path("open-missing");
EventLog::open(&path).unwrap();
assert!(path.exists());
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn opening_malformed_json_log_fails() {
let path = temp_log_path("malformed");
fs::write(&path, "{not json}\n").unwrap();
let err = EventLog::open(&path).expect_err("malformed JSONL must fail");
assert!(err.to_string().contains("parse event log"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_tampered_log_chain_fails() {
let path = temp_log_path("tampered");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
log.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
fs::write(&path, contents.replace("\"sequence\":2", "\"sequence\":3")).unwrap();
let err = EventLog::open(&path).expect_err("tampered chain must fail");
assert!(err.to_string().contains("sequence mismatch"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_log_with_unknown_record_field_fails() {
let path = temp_log_path("unknown-field");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
let mut record_json: Value =
serde_json::from_str(contents.lines().next().unwrap()).unwrap();
record_json["unexpected_field"] = json!("ignored-before-hashing");
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record_json).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unknown record fields must fail");
assert!(err.to_string().contains("parse event log"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_log_with_unsupported_schema_version_fails() {
let path = temp_log_path("unsupported-schema");
let mut record = EventRecord {
schema_version: 999,
sequence: 1,
timestamp_unix_secs: unix_secs_now(),
event_type: EventType::RuntimeTransition,
commitment_id: None,
runtime_state: RuntimeState::Transition,
payload_json: json!({"from": "active"}),
previous_hash: None,
hash: String::new(),
};
record.hash = record_hash(&record).unwrap();
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unsupported schema version must fail");
assert!(err.to_string().contains("unsupported schema version"));
fs::remove_file(path).unwrap();
}
#[test]
fn stale_handle_reloads_tail_before_append() {
let path = temp_log_path("stale-handle");
let mut log1 = EventLog::open(&path).unwrap();
let mut log2 = EventLog::open(&path).unwrap();
let first = log1
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
let second = log2
.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(second.sequence, 2);
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn append_creates_parent_dirs_and_writes_reopenable_file() {
let path = temp_log_path("append-parents")
.with_file_name("nested")
.join("events.jsonl");
let mut log = EventLog {
path: path.clone(),
next_sequence: 1,
previous_hash: None,
needs_parent_sync_after_append: false,
};
let record = log
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
assert_eq!(record.sequence, 1);
EventLog::open(&path).unwrap();
fs::remove_file(&path).unwrap();
fs::remove_dir(path.parent().unwrap()).unwrap();
}
}
-6
View File
@@ -1,6 +0,0 @@
pub mod context;
pub mod domain;
pub mod event_log;
pub mod session;
pub mod state_machine;
pub mod window;
-2121
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-327
View File
@@ -1,327 +0,0 @@
use crate::domain::{CommitmentState, RuntimeState};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuntimeAction {
EnterPlanning,
CancelOrTimeout,
Activate { policy_accepted: bool },
StartTransition,
CompleteForReview,
SevereViolation,
ReturnToActive,
SwitchTask,
EndTransitionForReview,
ContinuePlanning,
EndWorkPeriod,
EnterAdminOverride,
ExitAdminOverride(RuntimeState),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommitmentAction {
Activate,
PauseForTransition,
ReturnFromPause,
Complete,
Abandon,
Violate,
RecoverExplicitly,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TransitionError {
PolicyRejected,
IllegalRuntimeTransition {
current: RuntimeState,
action: RuntimeAction,
},
IllegalCommitmentTransition {
current: CommitmentState,
action: CommitmentAction,
},
InvalidAdminOverrideExitTarget {
target: RuntimeState,
},
}
impl std::fmt::Display for TransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransitionError::PolicyRejected => write!(f, "policy was rejected"),
TransitionError::IllegalRuntimeTransition { current, action } => {
write!(
f,
"illegal runtime transition from {:?} via {:?}",
current, action
)
}
TransitionError::IllegalCommitmentTransition { current, action } => {
write!(
f,
"illegal commitment transition from {:?} via {:?}",
current, action
)
}
TransitionError::InvalidAdminOverrideExitTarget { target } => {
write!(f, "invalid admin override exit target {:?}", target)
}
}
}
}
impl std::error::Error for TransitionError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompositeTransition {
pub runtime_state: RuntimeState,
pub commitment_state: CommitmentState,
}
pub fn transition_runtime(
current: RuntimeState,
action: RuntimeAction,
) -> Result<RuntimeState, TransitionError> {
use RuntimeAction::*;
use RuntimeState::*;
match (current, action) {
(Locked, EnterPlanning) => Ok(Planning),
(
Planning,
Activate {
policy_accepted: true,
},
) => Ok(Active),
(
Planning,
Activate {
policy_accepted: false,
},
) => Err(TransitionError::PolicyRejected),
(Planning, CancelOrTimeout) => Ok(Locked),
(Active, StartTransition) => Ok(Transition),
(Active, CompleteForReview) => Ok(Review),
(Active, SevereViolation) => Ok(Locked),
(Transition, ReturnToActive) => Ok(Active),
(Transition, SwitchTask) => Ok(Planning),
(Transition, EndTransitionForReview) => Ok(Review),
(Transition, CancelOrTimeout) => Ok(Locked),
(Review, ContinuePlanning) => Ok(Planning),
(Review, EndWorkPeriod) => Ok(Locked),
(_, EnterAdminOverride) => Ok(AdminOverride),
(AdminOverride, ExitAdminOverride(Locked)) => Ok(Locked),
(AdminOverride, ExitAdminOverride(Planning)) => Ok(Planning),
(AdminOverride, ExitAdminOverride(target)) => {
Err(TransitionError::InvalidAdminOverrideExitTarget { target })
}
_ => Err(TransitionError::IllegalRuntimeTransition { current, action }),
}
}
pub fn transition_commitment(
current: CommitmentState,
action: CommitmentAction,
) -> Result<CommitmentState, TransitionError> {
use CommitmentAction::*;
use CommitmentState::*;
match (current.clone(), action) {
(Draft, Activate) => Ok(Active),
(Active, PauseForTransition) => Ok(Paused),
(Paused, ReturnFromPause) => Ok(Active),
(Active, Complete) => Ok(Completed),
(Active, Abandon) => Ok(Abandoned),
(Active, Violate) => Ok(Violated),
(Paused, Abandon) => Ok(Abandoned),
(Violated, RecoverExplicitly) => Ok(Active),
(Violated, Abandon) => Ok(Abandoned),
_ => Err(TransitionError::IllegalCommitmentTransition { current, action }),
}
}
pub fn start_transition(
runtime_state: RuntimeState,
commitment_state: CommitmentState,
) -> Result<CompositeTransition, TransitionError> {
let next_runtime = transition_runtime(runtime_state, RuntimeAction::StartTransition)?;
let next_commitment =
transition_commitment(commitment_state, CommitmentAction::PauseForTransition)?;
Ok(CompositeTransition {
runtime_state: next_runtime,
commitment_state: next_commitment,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planning_can_only_activate_with_valid_policy() {
assert_eq!(
transition_runtime(
RuntimeState::Planning,
RuntimeAction::Activate {
policy_accepted: true,
}
),
Ok(RuntimeState::Active)
);
assert_eq!(
transition_runtime(
RuntimeState::Planning,
RuntimeAction::Activate {
policy_accepted: false,
}
),
Err(TransitionError::PolicyRejected)
);
}
#[test]
fn active_can_enter_transition_or_review_or_locked() {
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::StartTransition),
Ok(RuntimeState::Transition)
);
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::CompleteForReview),
Ok(RuntimeState::Review)
);
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::SevereViolation),
Ok(RuntimeState::Locked)
);
}
#[test]
fn locked_cannot_directly_become_active() {
let err = transition_runtime(
RuntimeState::Locked,
RuntimeAction::Activate {
policy_accepted: true,
},
)
.unwrap_err();
assert_eq!(
err,
TransitionError::IllegalRuntimeTransition {
current: RuntimeState::Locked,
action: RuntimeAction::Activate {
policy_accepted: true,
},
}
);
assert_eq!(
err.to_string(),
"illegal runtime transition from Locked via Activate { policy_accepted: true }"
);
}
#[test]
fn admin_override_cannot_exit_to_admin_override() {
let err = transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::AdminOverride),
)
.unwrap_err();
assert_eq!(
err,
TransitionError::InvalidAdminOverrideExitTarget {
target: RuntimeState::AdminOverride,
}
);
}
#[test]
fn admin_override_cannot_exit_to_active_or_transition() {
for target in [RuntimeState::Active, RuntimeState::Transition] {
let err = transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(target),
)
.unwrap_err();
assert_eq!(
err,
TransitionError::InvalidAdminOverrideExitTarget { target }
);
}
}
#[test]
fn admin_override_can_exit_to_locked_or_planning() {
assert_eq!(
transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::Locked),
),
Ok(RuntimeState::Locked)
);
assert_eq!(
transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::Planning),
),
Ok(RuntimeState::Planning)
);
}
#[test]
fn commitment_violation_recovery_is_explicit() {
assert_eq!(
transition_commitment(
CommitmentState::Violated,
CommitmentAction::RecoverExplicitly
),
Ok(CommitmentState::Active)
);
let err =
transition_commitment(CommitmentState::Violated, CommitmentAction::ReturnFromPause)
.unwrap_err();
assert_eq!(
err,
TransitionError::IllegalCommitmentTransition {
current: CommitmentState::Violated,
action: CommitmentAction::ReturnFromPause,
}
);
assert_eq!(
err.to_string(),
"illegal commitment transition from Violated via ReturnFromPause"
);
}
#[test]
fn start_transition_moves_runtime_and_commitment_together() {
assert_eq!(
start_transition(RuntimeState::Active, CommitmentState::Active),
Ok(CompositeTransition {
runtime_state: RuntimeState::Transition,
commitment_state: CommitmentState::Paused,
})
);
}
#[test]
fn start_transition_fails_before_returning_partial_state() {
assert_eq!(
start_transition(RuntimeState::Active, CommitmentState::Draft),
Err(TransitionError::IllegalCommitmentTransition {
current: CommitmentState::Draft,
action: CommitmentAction::PauseForTransition,
})
);
assert_eq!(
start_transition(RuntimeState::Locked, CommitmentState::Active),
Err(TransitionError::IllegalRuntimeTransition {
current: RuntimeState::Locked,
action: RuntimeAction::StartTransition,
})
);
}
}
-113
View File
@@ -1,113 +0,0 @@
use super::WindowSnapshot;
use crate::domain::EvidenceHealth;
use regex::Regex;
use std::{process::Command, process::Output, str};
pub fn get_title_clean() -> String {
get_snapshot().title
}
pub fn get_snapshot() -> WindowSnapshot {
let window_info = get_window_info();
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
let title = re.replace_all(&window_info.title, "").to_string();
let class = (window_info.class != "none").then_some(window_info.class);
let health = if window_info.wid.is_empty() {
EvidenceHealth::Unavailable("xdotool active window unavailable".to_string())
} else {
EvidenceHealth::Available
};
WindowSnapshot {
title,
class,
health,
}
}
pub fn minimize_other(title: &str) {
let window_info = get_window_info();
if window_info.wid.is_empty() {
return;
}
if window_info.title != title {
run_xdotool(&["windowminimize", &window_info.wid]);
}
}
struct WindowInfo {
title: String,
class: String,
wid: String,
}
fn is_valid_window_id(wid: &str) -> bool {
!wid.is_empty() && wid.bytes().all(|byte| byte.is_ascii_digit())
}
fn run_xdotool(args: &[&str]) -> Option<String> {
let output = Command::new("xdotool").args(args).output();
let Ok(Output {
status,
stdout,
stderr: _,
}) = output
else {
return None;
};
if status.code() != Some(0) {
return None;
}
let Ok(output_str) = str::from_utf8(&stdout) else {
return None;
};
Some(output_str.trim().to_string())
}
fn get_window_info() -> WindowInfo {
let none = WindowInfo {
title: "none".to_string(),
class: "none".to_string(),
wid: "".to_string(),
};
let Some(wid) = run_xdotool(&["getactivewindow"]) else {
return none;
};
if !is_valid_window_id(&wid) {
return none;
};
let Some(class) = run_xdotool(&["getwindowclassname", &wid]) else {
return none;
};
let Some(title) = run_xdotool(&["getwindowname", &wid]) else {
return none;
};
WindowInfo { title, class, wid }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_window_id_accepts_ascii_digits_only() {
assert!(is_valid_window_id("12345"));
assert!(is_valid_window_id("0"));
assert!(!is_valid_window_id(""));
assert!(!is_valid_window_id("12abc"));
assert!(!is_valid_window_id(" 123"));
assert!(!is_valid_window_id("123 "));
assert!(!is_valid_window_id("123"));
}
}
-45
View File
@@ -1,45 +0,0 @@
use crate::domain::EvidenceHealth;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WindowSnapshot {
pub title: String,
pub class: Option<String>,
pub health: EvidenceHealth,
}
impl WindowSnapshot {
pub fn unavailable(reason: impl Into<String>) -> Self {
Self {
title: "none".to_string(),
class: None,
health: EvidenceHealth::Unavailable(reason.into()),
}
}
}
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_snapshot_records_reason_without_evidence() {
assert_eq!(
WindowSnapshot::unavailable("no active window"),
WindowSnapshot {
title: "none".to_string(),
class: None,
health: EvidenceHealth::Unavailable("no active window".to_string()),
}
);
}
}
-68
View File
@@ -1,68 +0,0 @@
use super::WindowSnapshot;
use crate::domain::EvidenceHealth;
use regex::Regex;
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
use winapi::shared::windef::HWND;
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
pub fn get_title_clean() -> String {
get_snapshot().title
}
pub fn get_snapshot() -> WindowSnapshot {
let window_info = get_window_info();
if window_info.hwnd.is_null() {
return WindowSnapshot::unavailable("foreground window unavailable");
}
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
let title = re.replace_all(&window_info.title, "").to_string();
let health = if window_info.title.is_empty() {
EvidenceHealth::Degraded("foreground window title unavailable".to_string())
} else {
EvidenceHealth::Degraded("window class unavailable on current Windows adapter".to_string())
};
WindowSnapshot {
title,
class: None,
health,
}
}
pub fn minimize_other(title: &str) {
let window_info = get_window_info();
if window_info.hwnd.is_null() {
return;
}
if window_info.title != title {
unsafe {
ShowWindow(window_info.hwnd, SW_MINIMIZE);
}
}
}
struct WindowInfo {
title: String,
hwnd: HWND,
}
fn get_window_info() -> WindowInfo {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return WindowInfo {
title: String::new(),
hwnd,
};
}
let mut text: [u16; 512] = [0; 512];
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
let title = OsString::from_wide(&text[..len])
.to_string_lossy()
.into_owned();
WindowInfo { title, hwnd }
}
}