Compare commits
22 Commits
895ad342c7
...
14ec2b6ea8
| Author | SHA1 | Date | |
|---|---|---|---|
| 14ec2b6ea8 | |||
| b447432df0 | |||
| ca5e76baf5 | |||
| e5c32563d0 | |||
| 7eb2b5dc58 | |||
| 6b99804132 | |||
| 38e1a175b6 | |||
| ce201c57bb | |||
| 326990d69a | |||
| 324aa3ebce | |||
| 86c85629c0 | |||
| acf336c846 | |||
| a108964457 | |||
| 1a925b3a05 | |||
| 2a196bcdb2 | |||
| e3351b3d70 | |||
| ae5cec3dce | |||
| 3fdbfd307b | |||
| 4f6b26a220 | |||
| 71b8c80c69 | |||
| 1c81eb1b50 | |||
| d95aed0111 |
+49
-21
@@ -4,8 +4,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/knowledge"
|
||||
"antidrift/internal/session"
|
||||
"antidrift/internal/settings"
|
||||
"antidrift/internal/statusfile"
|
||||
"antidrift/internal/store"
|
||||
"antidrift/internal/tasks"
|
||||
@@ -36,33 +37,60 @@ func main() {
|
||||
srv := web.NewServer(ctrl)
|
||||
srv.Init() // re-arm or expire a restored Active session
|
||||
|
||||
// Wire the AI planning coach. Backend selectable via ANTIDRIFT_AI_BACKEND
|
||||
// (claude default, or codex). Misconfiguration degrades to no coach rather
|
||||
// than failing startup — manual planning still works.
|
||||
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
|
||||
log.Printf("ai disabled: %v", err)
|
||||
} else {
|
||||
// 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)
|
||||
log.Printf("ai: %s backend (coach + drift judge + nudge + reviewer)", backend.Name())
|
||||
ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))
|
||||
ctrl.SetKnowledgePath(s.KnowledgePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
|
||||
// overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute
|
||||
// path). A missing or failing CLI degrades to no tasks panel at fetch time —
|
||||
// planning still works.
|
||||
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
|
||||
log.Printf("tasks: marvin adapter (am)")
|
||||
|
||||
// Wire the Knowledge port: a single profile file grounding the coach. The
|
||||
// default path is overridable with ANTIDRIFT_KNOWLEDGE_FILE; unset falls back
|
||||
// to ~/.antidrift/knowledge.md. A missing/unreadable file degrades to an
|
||||
// ungrounded coach at load time — planning still works.
|
||||
ctrl.SetKnowledge(knowledge.NewFileSource(os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE")))
|
||||
log.Printf("knowledge: file adapter")
|
||||
// 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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,876 +0,0 @@
|
||||
# M3.5 — Semantic Nudge Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a soft, ambient AI nudge that catches *semantic* drift within an allowed app ("right app, wrong work") — the case the M3 drift interceptor is structurally blind to.
|
||||
|
||||
**Architecture:** A third advisor role, `ai.Nudger`, on the existing `ai.Service`, taking primitives only (leaf package preserved). `session.Controller` keeps a ring of recent window titles and, *only on the local-match on-task path* (where the drift judge stays silent), runs a debounced async nudge using the exact M3 discipline: generation-guarded, goroutine launched after the mutex is released, never fabricates a concern on error. The advisory surfaces over SSE as a new `DriftView.Nudge` field and renders as a dismiss-only "Heads up" tier on the active-view banner.
|
||||
|
||||
**Tech Stack:** Go 1.26, Gin, SSE, stdlib `testing` only (no testify). `go test -race ./...` and `go vet ./...` must stay clean.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md`
|
||||
|
||||
**Model selection:** Task 1 (isolated parser) and Tasks 3–6 (mechanical/trivial) → fast model. Task 2 (controller concurrency) → most capable model.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `internal/ai/nudge.go` | new: `Nudger` interface, `Service.Nudge`, `buildNudgePrompt`, `parseNudge`, `ErrInvalidNudge` |
|
||||
| `internal/ai/nudge_test.go` | new: `parseNudge` table tests |
|
||||
| `internal/session/session.go` | nudge fields + constants, `recentTitles` ring, `maybeNudgeLocked` + closure, `SetNudge`, `commitmentLineLocked`, `DriftView.Nudge`, `stateLocked` wiring, `resetDriftLocked` additions, `recentTitlesForTest` |
|
||||
| `internal/session/session_test.go` | `fakeNudger`/`nudgerFunc` + nudge tests |
|
||||
| `internal/web/web_test.go` | assert `nudge` field in serialized state |
|
||||
| `internal/web/static/index.html` | "Heads up" soft tier in `updateActiveDrift` + CSS + client dismiss |
|
||||
| `cmd/antidriftd/main.go` | `ctrl.SetNudge(svc)` + log line |
|
||||
| `README.md` | M3.5 paragraph in Status |
|
||||
|
||||
`internal/web/web.go` needs **no** change: `DriftView` lives in `session` and serializes via its JSON tags automatically.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ai.Nudger` role and response parsing
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ai/nudge.go`
|
||||
- Test: `internal/ai/nudge_test.go`
|
||||
|
||||
This task is self-contained in the `ai` leaf package. The `ai.Service` and its `Backend` already exist (`internal/ai/coach.go`): `Service` wraps a `Backend` and `Coach`/`JudgeDrift` are methods on it. `extractJSON`, `ErrEmptyResponse`, and `ErrNoJSON` are shared helpers defined in `internal/ai/proposal.go`. You are adding a third role, `Nudge`, that mirrors `JudgeDrift` (`internal/ai/coach.go:47-75`) and `parseVerdict` (`internal/ai/verdict.go`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `internal/ai/nudge_test.go`:
|
||||
|
||||
```go
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseNudge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{"on track yields empty", `{"on_track": true, "message": ""}`, "", nil},
|
||||
{"concern returns message", `{"on_track": false, "message": "editing unrelated CSS, not the auth flow"}`, "editing unrelated CSS, not the auth flow", nil},
|
||||
{"concern is trimmed", `{"on_track": false, "message": " drifted "}`, "drifted", nil},
|
||||
{"concern without message degrades to silence", `{"on_track": false, "message": ""}`, "", nil},
|
||||
{"json embedded in prose", `sure thing: {"on_track": false, "message": "wrong project"} done`, "wrong project", nil},
|
||||
{"empty response", " ", "", ErrEmptyResponse},
|
||||
{"no json", "no braces here", "", ErrNoJSON},
|
||||
{"malformed json", `{"on_track": false, "message":`, "", ErrInvalidNudge},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseNudge(tt.in)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/ai/ -run TestParseNudge`
|
||||
Expected: build failure — `undefined: parseNudge` and `undefined: ErrInvalidNudge`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `internal/ai/nudge.go`:
|
||||
|
||||
```go
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Nudger judges whether recent activity within an allowed app still serves the
|
||||
// commitment. Like Coach and DriftJudge it takes primitives, not domain/evidence
|
||||
// types, so ai stays a leaf package. The returned string is a one-sentence
|
||||
// advisory, or "" when the trajectory is still on-task.
|
||||
type Nudger interface {
|
||||
Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error)
|
||||
}
|
||||
|
||||
// ErrInvalidNudge marks a parseable-but-unusable nudge response.
|
||||
var ErrInvalidNudge = errors.New("ai: invalid nudge")
|
||||
|
||||
// Nudge makes Service satisfy Nudger over the same backend as Coach and JudgeDrift.
|
||||
func (s *Service) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
out, err := s.backend.Run(ctx, buildNudgePrompt(commitment, recentTitles))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseNudge(out)
|
||||
}
|
||||
|
||||
func buildNudgePrompt(commitment string, recentTitles []string) string {
|
||||
return `You are a focus monitor. The user committed to a task and is working in an allowed application, so the application itself is fine. Judge whether the SEQUENCE of recent window titles shows them still working toward the commitment, or whether they have drifted onto unrelated work within that app.
|
||||
|
||||
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||
{"on_track": <true or false>, "message": "<short explanation, one sentence>"}
|
||||
|
||||
Rules:
|
||||
- on_track: true if the recent titles plausibly serve the commitment, false if they show unrelated work.
|
||||
- message: one short sentence naming the drift. REQUIRED when on_track is false.
|
||||
|
||||
Commitment: ` + commitment + `
|
||||
Recent window titles (oldest to newest):
|
||||
` + strings.Join(recentTitles, "\n")
|
||||
}
|
||||
|
||||
// parseNudge extracts the advisory from raw CLI output. It reuses extractJSON
|
||||
// and the shared empty/no-JSON sentinels. An on-track result yields "". A
|
||||
// concern with no message degrades to "" (silence) rather than an error: the
|
||||
// nudge is ambient, so the safe degenerate is to say nothing, unlike the
|
||||
// interruptive drift verdict which rejects a reasonless drift.
|
||||
func parseNudge(s string) (string, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", ErrEmptyResponse
|
||||
}
|
||||
if strings.IndexByte(s, '{') < 0 {
|
||||
return "", ErrNoJSON
|
||||
}
|
||||
jsonStr, err := extractJSON(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
|
||||
}
|
||||
var raw struct {
|
||||
OnTrack bool `json:"on_track"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
|
||||
}
|
||||
if raw.OnTrack {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(raw.Message), nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `go test ./internal/ai/ -run TestParseNudge -v`
|
||||
Expected: PASS (all subtests).
|
||||
|
||||
- [ ] **Step 5: Vet and full ai-package test**
|
||||
|
||||
Run: `go vet ./internal/ai/ && go test ./internal/ai/`
|
||||
Expected: no vet output; PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/ai/nudge.go internal/ai/nudge_test.go
|
||||
git commit -m "Add ai.Nudger role for semantic drift within allowed apps
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Controller nudge state, ring, and orchestration
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session.go`
|
||||
- Test: `internal/session/session_test.go`
|
||||
|
||||
This is the concurrency-sensitive core. Study the existing drift orchestration first: the constants block (`internal/session/session.go:40-43`), the `Controller` struct fields (`:71-98`), `DriftView` (`:117-121`), `stateLocked`'s drift block (`:270-276`), `SetDriftJudge` (`:342-348`), `resetDriftLocked` (`:350-357`), `RecordWindow` (`:583-603`), and `evaluateDriftLocked` (`:605-679`). You will mirror that drift discipline exactly: a closure is built under the lock and returned to `RecordWindow`, which runs it via `go launch()` *after* unlocking; the closure re-acquires the lock, checks the generation guard, and calls `notify()` only after unlocking.
|
||||
|
||||
Key design facts:
|
||||
- The drift judge and the nudge are **mutually exclusive per observation**: the drift judge runs only when the window is *unmatched*; the nudge runs only when the window is a *local match* (on-task). So `RecordWindow`'s single `launch func()` return is sufficient — no second goroutine handle is needed.
|
||||
- The nudge reuses the existing `driftGen` counter as its session-identity guard (it is incremented only at session boundaries in `resetDriftLocked`), so a stale nudge is discarded exactly like a stale verdict.
|
||||
- Nudge state (`recentTitles`, `nudgeMessage`, `lastNudgedAt`) is in-memory only and cleared in `resetDriftLocked`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to the imports of `internal/session/session_test.go` (it currently imports `context`, `errors`, `path/filepath`, `sync/atomic`, `testing`, `time`, and the internal packages): add `"fmt"`.
|
||||
|
||||
Append these test helpers and tests to `internal/session/session_test.go`:
|
||||
|
||||
```go
|
||||
// fakeNudger is a controllable ai.Nudger for tests. An optional gate channel
|
||||
// blocks the call until closed, mirroring fakeJudge.
|
||||
type fakeNudger struct {
|
||||
msg string
|
||||
err error
|
||||
gate chan struct{}
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (f *fakeNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
atomic.AddInt32(&f.calls, 1)
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.msg, f.err
|
||||
}
|
||||
|
||||
// nudgerFunc adapts a function to ai.Nudger for tests needing per-call results.
|
||||
type nudgerFunc func(context.Context, string, []string) (string, error)
|
||||
|
||||
func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, error) {
|
||||
return f(ctx, c, t)
|
||||
}
|
||||
|
||||
func waitNudge(t *testing.T, c *Controller, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := c.State()
|
||||
if st.Drift != nil && st.Drift.Nudge == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("nudge never reached %q (last: %+v)", want, c.State().Drift)
|
||||
}
|
||||
|
||||
func TestNudgeFiresOnOnTaskPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "drifted to an unrelated file"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "auth.go")) // history 1 -> not yet eligible
|
||||
c.RecordWindow(obs("code", "styles.css")) // history 2 -> eligible, launches
|
||||
waitNudge(t, c, "drifted to an unrelated file")
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("expected exactly one nudge call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "should not run"}
|
||||
c.SetNudge(fn)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
c.RecordWindow(obs("firefox", "Reddit"))
|
||||
waitDriftStatus(t, c, "drifting")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
||||
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDebounced(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
fn := &fakeNudger{msg: "drift"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // fires nudge #1
|
||||
waitNudge(t, c, "drift")
|
||||
c.RecordWindow(obs("code", "c.go")) // within debounce window -> suppressed
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("debounce should allow one nudge, calls=%d", got)
|
||||
}
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "d.go")) // debounce elapsed -> nudge #2
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) < 2 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 2 {
|
||||
t.Fatalf("expected second nudge after debounce, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeErrorDoesNotFabricate(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{err: errors.New("backend down")}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
|
||||
t.Fatalf("error must not set a nudge message: %+v", st.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeClearsWhenBackOnTrack(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
msgs := make(chan string, 2)
|
||||
msgs <- "drifted"
|
||||
msgs <- "" // back on track
|
||||
c.SetNudge(nudgerFunc(func(context.Context, string, []string) (string, error) {
|
||||
return <-msgs, nil
|
||||
}))
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
waitNudge(t, c, "drifted")
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "c.go"))
|
||||
waitNudge(t, c, "") // an on-track result clears the prior advisory
|
||||
}
|
||||
|
||||
func TestNilNudgerStaysSilentOnTask(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
st := c.State()
|
||||
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||
t.Fatalf("expected ontask, got %+v", st.Drift)
|
||||
}
|
||||
if st.Drift.Nudge != "" {
|
||||
t.Fatalf("nil nudger must stay silent, got %q", st.Drift.Nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "late", gate: make(chan struct{})}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // launches nudge, blocks on gate
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
_ = c.Complete()
|
||||
_ = c.End()
|
||||
close(fn.gate) // release stale goroutine; result must be discarded
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if c.State().RuntimeState != domain.RuntimeLocked {
|
||||
t.Fatalf("should be Locked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTitlesRingCapsAtTen(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
for i := 0; i < 15; i++ {
|
||||
c.RecordWindow(obs("code", fmt.Sprintf("file%d.go", i)))
|
||||
}
|
||||
titles := c.recentTitlesForTest()
|
||||
if len(titles) != 10 {
|
||||
t.Fatalf("ring should cap at 10, got %d", len(titles))
|
||||
}
|
||||
if titles[0] != "file5.go" {
|
||||
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles'`
|
||||
Expected: build failure — `c.SetNudge`, `c.recentTitlesForTest`, and `nudgeDebounce` are undefined.
|
||||
|
||||
- [ ] **Step 3: Add constants and struct fields**
|
||||
|
||||
In `internal/session/session.go`, extend the drift constants block (currently `internal/session/session.go:40-43`) and add a ring-size constant:
|
||||
|
||||
```go
|
||||
const (
|
||||
driftDebounce = 10 * time.Second
|
||||
driftTimeout = 30 * time.Second
|
||||
nudgeDebounce = 5 * time.Minute
|
||||
nudgeTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
const recentTitlesMax = 10
|
||||
```
|
||||
|
||||
Add nudge fields to the `Controller` struct, immediately after the `judgedClasses` field (`internal/session/session.go:97`):
|
||||
|
||||
```go
|
||||
judgedClasses map[string]ai.Verdict
|
||||
|
||||
nudge ai.Nudger
|
||||
recentTitles []string // in-memory ring of recent distinct titles this session
|
||||
nudgeMessage string // current soft advisory ("" = none)
|
||||
lastNudgedAt time.Time
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `Nudge` field to `DriftView`**
|
||||
|
||||
Replace `DriftView` (`internal/session/session.go:117-121`):
|
||||
|
||||
```go
|
||||
// DriftView is the active-only drift projection. Nudge is a separate axis from
|
||||
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
||||
// inside an allowed app), so it cannot be folded into the status enum.
|
||||
type DriftView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Nudge string `json:"nudge,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire the nudge message into `stateLocked`**
|
||||
|
||||
Replace the drift block in `stateLocked` (`internal/session/session.go:270-276`):
|
||||
|
||||
```go
|
||||
if c.runtimeState == domain.RuntimeActive {
|
||||
status := c.driftStatus
|
||||
if status == "" {
|
||||
status = driftIdle
|
||||
}
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `SetNudge`, `commitmentLineLocked`, and clear nudge state in `resetDriftLocked`**
|
||||
|
||||
Add `SetNudge` next to `SetDriftJudge` (after `internal/session/session.go:348`):
|
||||
|
||||
```go
|
||||
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||
// local matching and the drift judge are unaffected.
|
||||
func (c *Controller) SetNudge(n ai.Nudger) {
|
||||
c.mu.Lock()
|
||||
c.nudge = n
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// commitmentLineLocked renders the active commitment as a single line for AI
|
||||
// prompts, or "" if there is none. Caller holds mu.
|
||||
func (c *Controller) commitmentLineLocked() string {
|
||||
if c.commitment == nil {
|
||||
return ""
|
||||
}
|
||||
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||
}
|
||||
|
||||
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
||||
func (c *Controller) recentTitlesForTest() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.recentTitles...)
|
||||
}
|
||||
```
|
||||
|
||||
Extend `resetDriftLocked` (`internal/session/session.go:350-357`) to clear nudge state too:
|
||||
|
||||
```go
|
||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||
func (c *Controller) resetDriftLocked() {
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
c.driftGen++
|
||||
c.lastJudgedAt = time.Time{}
|
||||
c.judgedClasses = map[string]ai.Verdict{}
|
||||
c.recentTitles = nil
|
||||
c.nudgeMessage = ""
|
||||
c.lastNudgedAt = time.Time{}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Record the recent-titles ring and DRY the commitment line**
|
||||
|
||||
Add the ring helper near `applyEvent` in `internal/session/session.go`:
|
||||
|
||||
```go
|
||||
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
||||
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
||||
func (c *Controller) recordTitleLocked(title string) {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return
|
||||
}
|
||||
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
|
||||
return
|
||||
}
|
||||
c.recentTitles = append(c.recentTitles, t)
|
||||
if len(c.recentTitles) > recentTitlesMax {
|
||||
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `RecordWindow`, record the title right after `c.applyEvent(now, snap)` (`internal/session/session.go:596`):
|
||||
|
||||
```go
|
||||
c.applyEvent(now, snap)
|
||||
c.recordTitleLocked(snap.Title)
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
```
|
||||
|
||||
Apply the DRY refactor in `evaluateDriftLocked`: replace the inline commitment build (`internal/session/session.go:646-649`) with the helper:
|
||||
|
||||
```go
|
||||
prevStatus, prevReason := c.driftStatus, c.driftReason
|
||||
c.driftGen++
|
||||
gen := c.driftGen
|
||||
c.lastJudgedAt = now
|
||||
c.driftStatus = driftPending
|
||||
c.driftReason = ""
|
||||
judge := c.judge
|
||||
commitment := c.commitmentLineLocked()
|
||||
```
|
||||
|
||||
(The closure below still references `commitment`, `class`, `title`, `gen`, `prevStatus`, `prevReason` unchanged.)
|
||||
|
||||
- [ ] **Step 8: Launch the nudge from the on-task local-match branch**
|
||||
|
||||
In `evaluateDriftLocked`, replace step 1 (`internal/session/session.go:613-619`) so the on-task branch returns the nudge closure when eligible:
|
||||
|
||||
```go
|
||||
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
||||
// where the semantic nudge runs — the drift judge stays silent here, so the
|
||||
// two are mutually exclusive per observation.
|
||||
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||
if evidence.MatchesAllowed(ac, class, title) {
|
||||
c.driftStatus = driftOnTask
|
||||
c.driftReason = ""
|
||||
return c.maybeNudgeLocked(now)
|
||||
}
|
||||
```
|
||||
|
||||
Add `maybeNudgeLocked` after `evaluateDriftLocked` (before `applyVerdictLocked`):
|
||||
|
||||
```go
|
||||
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
|
||||
// local-match path. It returns the nudging closure when eligible (judge wired,
|
||||
// at least two titles of history, debounce elapsed), else nil. The closure
|
||||
// follows the drift-judge discipline: it runs in a goroutine after the caller
|
||||
// releases the mutex, re-acquires the lock, guards on driftGen, never fabricates
|
||||
// a concern on error, and notifies only after unlocking. Caller holds mu and has
|
||||
// already verified the runtime is Active.
|
||||
func (c *Controller) maybeNudgeLocked(now time.Time) func() {
|
||||
if c.nudge == nil || len(c.recentTitles) < 2 {
|
||||
return nil
|
||||
}
|
||||
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
|
||||
return nil
|
||||
}
|
||||
c.lastNudgedAt = now
|
||||
gen := c.driftGen
|
||||
nudge := c.nudge
|
||||
commitment := c.commitmentLineLocked()
|
||||
titles := append([]string(nil), c.recentTitles...)
|
||||
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
|
||||
defer cancel()
|
||||
msg, err := nudge.Nudge(ctx, commitment, titles)
|
||||
|
||||
c.mu.Lock()
|
||||
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
||||
c.mu.Unlock()
|
||||
return // stale: a new session started or the session ended
|
||||
}
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
log.Printf("session: nudge failed: %v", err)
|
||||
return // never fabricate a concern; leave any prior message intact
|
||||
}
|
||||
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'TestNudge|TestNilNudger|TestStaleNudge|TestRecentTitles' -v`
|
||||
Expected: PASS for all eight new tests.
|
||||
|
||||
- [ ] **Step 10: Run the full session suite under the race detector**
|
||||
|
||||
Run: `go test -race ./internal/session/`
|
||||
Expected: PASS (all existing drift tests still green; no data races).
|
||||
|
||||
- [ ] **Step 11: Vet**
|
||||
|
||||
Run: `go vet ./internal/session/`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session.go internal/session/session_test.go
|
||||
git commit -m "Orchestrate semantic nudge on the on-task path
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Assert the nudge surfaces in the SSE state payload
|
||||
|
||||
**Files:**
|
||||
- Test: `internal/web/web_test.go`
|
||||
|
||||
`internal/web/web.go` needs no change — `DriftView.Nudge` serializes automatically. This task adds a web-package test proving the field reaches the wire. Reuse `newTestServer`/`post` (`internal/web/web_test.go:20-38`) and the `stubCoach` pattern (`:105-112`). The `/commitment` route already accepts `allowed_window_classes` (added in M3). `context` is already imported in this test file.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `internal/web/web_test.go`:
|
||||
|
||||
```go
|
||||
type stubNudger struct{ msg string }
|
||||
|
||||
func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
return s.msg, nil
|
||||
}
|
||||
|
||||
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Two distinct on-task titles trip the nudge (history >= 2, first call has no
|
||||
// debounce to wait on).
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"nudge":"drifted to unrelated work"`) {
|
||||
t.Fatalf("payload missing nudge: %s", js)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails first, then passes**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestActiveStatePayloadCarriesNudge -v`
|
||||
Expected: PASS (the implementation already exists from Task 2; this guards the serialization). If it fails to compile because `stubNudger` is unused elsewhere, that is fine — it is used by this test.
|
||||
|
||||
- [ ] **Step 3: Run the full web suite and vet**
|
||||
|
||||
Run: `go test ./internal/web/ && go vet ./internal/web/`
|
||||
Expected: PASS; no vet output.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/web_test.go
|
||||
git commit -m "Assert semantic nudge reaches the state payload
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Active-view "Heads up" soft tier
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/index.html`
|
||||
|
||||
The active view already renders drift from `state.drift` via `updateActiveDrift(drift)` (`internal/web/static/index.html:80-101`), called both on the active fast-path (`:135-137`) and full render (`:188`). The `#drift` div is the first child of the active view (`:177`). You will add a soft, dismiss-only "Heads up" tier that shows when `drift.nudge` is non-empty and the user is not drifting. Match the existing un-escaped interpolation style used for `drift.reason` (these are short, locally generated strings).
|
||||
|
||||
- [ ] **Step 1: Add the nudge CSS**
|
||||
|
||||
After the existing `.drift-hint` rule (`internal/web/static/index.html:36`), add:
|
||||
|
||||
```css
|
||||
.nudge { background: #1d2630; border: 1px solid #2f4255; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px; }
|
||||
.nudge-title { font-weight: 600; color: #8fb6d9; }
|
||||
.nudge-msg { color: #c2d2e0; margin: 4px 0 8px; }
|
||||
.nudge-actions button { padding: 6px 12px; background: #2d3242; color: #cdd2e0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the dismiss state and the nudge tier**
|
||||
|
||||
Add a module-level variable next to `let renderedState = null;` (`internal/web/static/index.html:47`):
|
||||
|
||||
```js
|
||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||
```
|
||||
|
||||
Replace `updateActiveDrift` (`internal/web/static/index.html:80-101`) with:
|
||||
|
||||
```js
|
||||
function updateActiveDrift(drift) {
|
||||
const el = document.getElementById('drift');
|
||||
if (!el) return;
|
||||
const status = drift && drift.status;
|
||||
if (status === 'drifting') {
|
||||
el.innerHTML = `<div class="drift">
|
||||
<div class="drift-title">⚠ Possible drift</div>
|
||||
<div class="drift-reason">${drift.reason || ''}</div>
|
||||
<div class="drift-actions">
|
||||
<button id="refocus" type="button">Back to task</button>
|
||||
<button id="ontask" type="button" class="secondary">This is on task</button>
|
||||
<button id="enddrift" type="button" class="secondary">End session</button>
|
||||
</div></div>`;
|
||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||
return;
|
||||
}
|
||||
const nudge = (drift && drift.nudge) || '';
|
||||
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
|
||||
if (nudge && nudge !== dismissedNudge) {
|
||||
el.innerHTML = `<div class="nudge">
|
||||
<div class="nudge-title">Heads up</div>
|
||||
<div class="nudge-msg">${nudge}</div>
|
||||
<div class="nudge-actions">
|
||||
<button id="dismissnudge" type="button">Dismiss</button>
|
||||
</div></div>`;
|
||||
document.getElementById('dismissnudge').onclick = () => {
|
||||
dismissedNudge = nudge;
|
||||
const e = document.getElementById('drift');
|
||||
if (e) e.innerHTML = '';
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (status === 'pending') {
|
||||
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the build still serves the page**
|
||||
|
||||
Run: `go build ./... && go vet ./...`
|
||||
Expected: no output (the HTML is embedded; this confirms nothing else broke).
|
||||
|
||||
- [ ] **Step 4: Manual smoke check of the markup (optional but recommended)**
|
||||
|
||||
Run: `grep -n "Heads up" internal/web/static/index.html`
|
||||
Expected: one match inside `updateActiveDrift`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/index.html
|
||||
git commit -m "Add dismiss-only Heads up tier for semantic nudges
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire the nudge into the daemon
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/antidriftd/main.go`
|
||||
|
||||
The single `ai.Service` already satisfies `Coach` and `DriftJudge` and is wired at `cmd/antidriftd/main.go:41-44`. Add the third role.
|
||||
|
||||
- [ ] **Step 1: Add `SetNudge` and update the log line**
|
||||
|
||||
Replace the AI-wiring body (`cmd/antidriftd/main.go:41-44`):
|
||||
|
||||
```go
|
||||
svc := ai.NewService(backend)
|
||||
ctrl.SetCoach(svc)
|
||||
ctrl.SetDriftJudge(svc)
|
||||
ctrl.SetNudge(svc)
|
||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and vet**
|
||||
|
||||
Run: `go build ./cmd/antidriftd && go vet ./cmd/antidriftd`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/antidriftd/main.go
|
||||
git commit -m "Wire the semantic nudge into the daemon
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Document M3.5 in the README
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
The Status section currently leads with the M3 paragraph (`README.md:26-32`). Add an M3.5 paragraph above it so the newest milestone is first.
|
||||
|
||||
- [ ] **Step 1: Insert the M3.5 paragraph**
|
||||
|
||||
Immediately after the `## Status` heading (`README.md:24`) and its blank line, insert:
|
||||
|
||||
```markdown
|
||||
M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
|
||||
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
|
||||
only while you are on-task in an allowed app — periodically reads your recent
|
||||
window titles and, if the trajectory has wandered from the commitment, shows a
|
||||
soft, dismissible "Heads up" line (no interrupt, no buttons to fight). It is
|
||||
debounced to roughly one check every five minutes, reuses the same CLI backend
|
||||
as the coach and drift judge, and degrades gracefully — without it, everything
|
||||
else still works.
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify placement**
|
||||
|
||||
Run: `grep -n "M3.5\|M3 (drift\|M2 (AI" README.md`
|
||||
Expected: `M3.5` line appears before the `M3 (drift` line, which appears before `M2 (AI`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "Document M3.5 semantic nudge in README
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Review
|
||||
|
||||
After all tasks, dispatch a final whole-diff code review and run the complete suite:
|
||||
|
||||
```bash
|
||||
go build ./... && go vet ./... && go test -race ./...
|
||||
```
|
||||
|
||||
Expected: clean build, no vet output, all tests pass under the race detector.
|
||||
|
||||
Confirm end-to-end: with a real AI backend, an Active session whose allowed list includes the focused app, sitting in that app while the window titles wander off-commitment, should — after ~5 min and ≥2 distinct titles — surface a soft "Heads up" line that the user can dismiss, with no hard interrupt and no overlap with the drift banner. Without a backend (`SetNudge(nil)` path), nothing changes.
|
||||
```
|
||||
@@ -1,625 +0,0 @@
|
||||
# M4 — "Look good" Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restyle the AntiDrift web UI into a cockpit-style, state-aware stacked HUD, split CSS/JS out of the inline HTML, and polish the review screen — with zero behavior changes.
|
||||
|
||||
**Architecture:** The UI is a single embedded static bundle served by `internal/web`. Today everything lives inline in `static/index.html`. We split it into `index.html` + `app.css` + `app.js`, add Go routes to serve the two new assets from the existing `embed.FS`, then rework the client markup into reusable "band" rows whose accent color is driven by a `data-state` attribute on `<main>`. All SSE wiring, element ids, and POST endpoints are preserved, so the existing Go endpoint tests keep passing.
|
||||
|
||||
**Tech Stack:** Go 1.26, Gin, `embed.FS`, server-sent events; plain HTML/CSS/vanilla JS (no framework, no build step). Tests: stdlib `testing` + `net/http/httptest`.
|
||||
|
||||
---
|
||||
|
||||
## Reference: shared CSS class vocabulary
|
||||
|
||||
These class/attribute names are used by BOTH `app.css` and `app.js`. Keep them identical across tasks:
|
||||
|
||||
- `<main id="app" data-state="...">` — `data-state` ∈ `locked | planning | active | nudge | drift | review`. Drives `--accent`.
|
||||
- `.band` — a horizontal HUD row (top divider + padding). `.band:first-child` has no top border.
|
||||
- `.statusband` — the top band; shows the state pill + status meta, takes a thicker accent top border.
|
||||
- `.pill` — uppercase state label, colored by `--accent`.
|
||||
- `.status-meta` — dim text beside the pill (e.g. "on task · 7 switches").
|
||||
- `.timer` — large tabular-nums countdown.
|
||||
- `.action` — the next-action line. `.meta` — dim secondary text (e.g. "done when: …").
|
||||
- `.evidence` (band) with children `.now`, `.buckets` (`<ul>`/`<li>`), `.switches`; health spans `.health-ok` / `.health-bad`.
|
||||
- `.summary` (review band) with `.summary-row` children.
|
||||
- Buttons: `.btn`, `.btn-primary`, `.btn-ghost`.
|
||||
- The `data-state` value is derived by `stateKey(state)` (defined in Task 2), which accounts for the client-side `dismissedNudge`.
|
||||
|
||||
Preserved element ids (do not rename): `#app`, `#intent`, `#sharpen`, `#coachStatus`, `#na`, `#sc`, `#mins`, `#apps`, `#start`, `#statusband`, `#t`, `#done`, `#end`, `#plan`, `#refocus`, `#ontask`, `#enddrift`, `#dismissnudge`.
|
||||
|
||||
Preserved endpoints: `GET /events`; `POST /planning /coach /commitment /complete /end /refocus /ontask`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Split assets out of the inline HTML and serve them
|
||||
|
||||
Mechanical, behavior-preserving: move the inline `<style>`/`<script>` into `app.css`/`app.js`, link them, and add Go routes so they are served from the embedded FS. No visual or logic change yet.
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/web/static/app.css`
|
||||
- Create: `internal/web/static/app.js`
|
||||
- Modify: `internal/web/static/index.html` (replace whole file)
|
||||
- Modify: `internal/web/web.go:39-56` (add two asset routes)
|
||||
- Test: `internal/web/web_test.go` (add one test func)
|
||||
|
||||
- [ ] **Step 1: Write the failing test for asset serving**
|
||||
|
||||
Add to `internal/web/web_test.go`:
|
||||
|
||||
```go
|
||||
func TestServesStaticAssets(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
ctSubstr string
|
||||
}{
|
||||
{"/app.css", "css"},
|
||||
{"/app.js", "javascript"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("GET %s code = %d, want 200", tc.path, w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, tc.ctSubstr) {
|
||||
t.Errorf("GET %s Content-Type = %q, want substring %q", tc.path, ct, tc.ctSubstr)
|
||||
}
|
||||
if w.Body.Len() == 0 {
|
||||
t.Errorf("GET %s body is empty", tc.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestServesStaticAssets -v`
|
||||
Expected: FAIL — routes return 404 (and the embedded files don't exist yet).
|
||||
|
||||
- [ ] **Step 3: Create `app.css` by moving the inline styles verbatim**
|
||||
|
||||
Cut the entire current contents of the `<style>…</style>` block in `internal/web/static/index.html` (everything between the tags, currently lines 8–41) and paste it unchanged into the new file `internal/web/static/app.css`. Do not edit the rules in this task — this is a pure move. (They are replaced wholesale in Task 2.)
|
||||
|
||||
- [ ] **Step 4: Create `app.js` by moving the inline script verbatim**
|
||||
|
||||
Cut the entire current contents of the `<script>…</script>` block in `internal/web/static/index.html` (everything between the tags, currently lines 49–227) and paste it unchanged into the new file `internal/web/static/app.js`.
|
||||
|
||||
- [ ] **Step 5: Replace `index.html` with the markup shell**
|
||||
|
||||
Replace the whole file `internal/web/static/index.html` with:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AntiDrift</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<h1>AntiDrift</h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Note: the script must reference `view` and (from Task 2 on) `app`; both ids exist here. The script tag is at the end of `<body>`, so the DOM is parsed before it runs (matches the current inline-at-end-of-body behavior).
|
||||
|
||||
- [ ] **Step 6: Add asset routes in `web.go`**
|
||||
|
||||
In `internal/web/web.go`, the `Router()` method currently has (around lines 43–46):
|
||||
|
||||
```go
|
||||
sub, _ := fs.Sub(staticFS, "static")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.FileFromFS("/", http.FS(sub))
|
||||
})
|
||||
```
|
||||
|
||||
Add the two asset routes immediately after the `r.GET("/", …)` block:
|
||||
|
||||
```go
|
||||
r.GET("/app.css", func(c *gin.Context) {
|
||||
c.FileFromFS("/app.css", http.FS(sub))
|
||||
})
|
||||
r.GET("/app.js", func(c *gin.Context) {
|
||||
c.FileFromFS("/app.js", http.FS(sub))
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the asset test and the full web suite**
|
||||
|
||||
Run: `go test ./internal/web/ -v`
|
||||
Expected: PASS — `TestServesStaticAssets` passes and all pre-existing web tests still pass (they assert on endpoints/JSON, not markup).
|
||||
|
||||
- [ ] **Step 8: Verify vet and the whole module**
|
||||
|
||||
Run: `go vet ./... && go test -race ./...`
|
||||
Expected: clean, all green.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/index.html internal/web/static/app.css internal/web/static/app.js internal/web/web.go internal/web/web_test.go
|
||||
git commit -m "M4: split web UI assets into app.css and app.js
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Cockpit visual system + locked/planning/active/drift/nudge
|
||||
|
||||
Replace `app.css` with the new design system and rewrite `app.js`'s rendering into stacked bands with a `data-state`-driven accent. Review stays a simple functional band here; Task 3 turns it into the recap. No Go changes — the existing endpoint tests are the regression guard.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.css` (replace whole file)
|
||||
- Modify: `internal/web/static/app.js` (replace whole file)
|
||||
- Test: existing `internal/web/web_test.go` (regression only — no new test)
|
||||
|
||||
- [ ] **Step 1: Replace `app.css` with the cockpit design system**
|
||||
|
||||
Replace the whole file `internal/web/static/app.css` with:
|
||||
|
||||
```css
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0d0f14;
|
||||
--panel: #161922;
|
||||
--line: #232733;
|
||||
--ink: #e6e8ee;
|
||||
--ink-dim: #8b91a6;
|
||||
--ok: #34d399;
|
||||
--warn: #f0b429;
|
||||
--danger: #f06070;
|
||||
--accent: #6b7280; /* default / locked */
|
||||
}
|
||||
|
||||
/* The single state-driven knob: only --accent changes per state. */
|
||||
[data-state="planning"] { --accent: #4c6ef5; }
|
||||
[data-state="active"] { --accent: #34d399; }
|
||||
[data-state="nudge"] { --accent: #f0b429; }
|
||||
[data-state="drift"] { --accent: #f06070; }
|
||||
[data-state="review"] { --accent: #a78bfa; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15px/1.5 system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
main { max-width: 560px; margin: 7vh auto; padding: 0 20px; }
|
||||
|
||||
h1 {
|
||||
font-size: 12px; letter-spacing: .28em; text-transform: uppercase;
|
||||
color: var(--ink-dim); margin: 0 0 14px 6px;
|
||||
}
|
||||
|
||||
/* The HUD card: a stack of bands. */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.band { border-top: 1px solid var(--line); padding: 16px 20px; }
|
||||
.band:first-child { border-top: 0; }
|
||||
|
||||
.statusband {
|
||||
display: flex; align-items: baseline; gap: 12px;
|
||||
border-top: 3px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--panel));
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px; letter-spacing: .18em; text-transform: uppercase;
|
||||
font-weight: 700; color: var(--accent);
|
||||
}
|
||||
.status-meta { font-size: 13px; color: var(--ink-dim); }
|
||||
|
||||
.timer {
|
||||
font-size: 52px; font-weight: 700; line-height: 1;
|
||||
font-variant-numeric: tabular-nums; color: var(--ink);
|
||||
}
|
||||
|
||||
.action { font-size: 19px; font-weight: 600; }
|
||||
.meta { color: var(--ink-dim); margin: 4px 0 0; }
|
||||
|
||||
label { display: block; font-size: 12px; color: var(--ink-dim); margin: 14px 0 5px; }
|
||||
label:first-child { margin-top: 0; }
|
||||
input {
|
||||
width: 100%; padding: 10px 12px;
|
||||
background: var(--bg); border: 1px solid var(--line);
|
||||
border-radius: 8px; color: var(--ink); font: inherit;
|
||||
}
|
||||
input:focus { outline: 0; border-color: var(--accent); }
|
||||
|
||||
.btn {
|
||||
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
|
||||
font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #0d0f14; }
|
||||
.btn-ghost { background: var(--line); color: var(--ink); }
|
||||
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
|
||||
|
||||
/* Drift/nudge actions live in the status band; lay them out below the text. */
|
||||
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
|
||||
|
||||
.drift-reason, .nudge-msg { color: var(--ink); margin: 4px 0 0; font-weight: 400; }
|
||||
.statusband.col { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
|
||||
/* Evidence band */
|
||||
.evidence .now { font-size: 13px; color: var(--ink); }
|
||||
.health-ok { color: var(--ok); }
|
||||
.health-bad { color: var(--warn); }
|
||||
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: var(--ink-dim); }
|
||||
.buckets li {
|
||||
display: flex; justify-content: space-between; padding: 2px 0;
|
||||
font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.switches { font-size: 12px; color: var(--ink-dim); margin-top: 8px; }
|
||||
|
||||
/* Review summary band (built out in Task 3) */
|
||||
.summary { font-size: 14px; }
|
||||
.summary-row {
|
||||
display: flex; justify-content: space-between; padding: 3px 0;
|
||||
font-variant-numeric: tabular-nums; color: var(--ink-dim);
|
||||
}
|
||||
.summary-row span:last-child { color: var(--ink); font-family: ui-monospace, monospace; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `app.js` with the band-based renderer**
|
||||
|
||||
Replace the whole file `internal/web/static/app.js` with:
|
||||
|
||||
```js
|
||||
const app = document.getElementById('app');
|
||||
const view = document.getElementById('view');
|
||||
let countdownTimer = null;
|
||||
let renderedState = null; // which runtime_state the DOM currently shows
|
||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(secs) {
|
||||
secs = Math.max(0, Math.floor(secs));
|
||||
const m = String(Math.floor(secs / 60)).padStart(2, '0');
|
||||
const s = String(secs % 60).padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
// stateKey maps server state to the data-state accent bucket. Drift outranks
|
||||
// a nudge, and a nudge the user already dismissed reverts to plain active.
|
||||
function stateKey(state) {
|
||||
const rs = state.runtime_state;
|
||||
if (rs !== 'active') return rs || 'locked';
|
||||
const d = state.drift || {};
|
||||
if (d.status === 'drifting') return 'drift';
|
||||
if (d.nudge && d.nudge !== dismissedNudge) return 'nudge';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function setStateAttr(state) {
|
||||
app.dataset.state = stateKey(state);
|
||||
}
|
||||
|
||||
function evidenceBlock(ev) {
|
||||
if (!ev) return '';
|
||||
const health = ev.available
|
||||
? `<span class="health-ok">tracking</span>`
|
||||
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
||||
const now = ev.current && (ev.current.class || ev.current.title)
|
||||
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
|
||||
const rows = (ev.buckets || []).map(b =>
|
||||
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
|
||||
return `<div class="band evidence">
|
||||
<div class="now">now ${now} ${health}</div>
|
||||
<ul class="buckets">${rows}</ul>
|
||||
<div class="switches">context switches: ${ev.switch_count || 0}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// updateActiveDrift owns the active status band: it renders on-task, nudge,
|
||||
// drift, or pending content into #statusband and rebinds the buttons.
|
||||
function updateActiveDrift(state) {
|
||||
const el = document.getElementById('statusband');
|
||||
if (!el) return;
|
||||
setStateAttr(state);
|
||||
const drift = state.drift || {};
|
||||
const switches = (state.evidence && state.evidence.switch_count) || 0;
|
||||
|
||||
if (drift.status === 'drifting') {
|
||||
el.className = 'band statusband col';
|
||||
el.innerHTML = `<div><span class="pill">Drift</span></div>
|
||||
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
|
||||
<div class="band-actions">
|
||||
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
|
||||
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
||||
</div>`;
|
||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||
return;
|
||||
}
|
||||
|
||||
const nudge = drift.nudge || '';
|
||||
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
|
||||
if (nudge && nudge !== dismissedNudge) {
|
||||
el.className = 'band statusband col';
|
||||
el.innerHTML = `<div><span class="pill">Heads up</span></div>
|
||||
<div class="nudge-msg">${nudge}</div>
|
||||
<div class="band-actions">
|
||||
<button id="dismissnudge" type="button" class="btn btn-ghost">Dismiss</button>
|
||||
</div>`;
|
||||
document.getElementById('dismissnudge').onclick = () => {
|
||||
dismissedNudge = nudge;
|
||||
setStateAttr(state);
|
||||
updateActiveDrift(state);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (drift.status === 'pending') {
|
||||
el.className = 'band statusband';
|
||||
el.innerHTML = `<span class="pill">Active</span><span class="status-meta">checking focus…</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.className = 'band statusband';
|
||||
el.innerHTML = `<span class="pill">Active</span>
|
||||
<span class="status-meta">on task · ${switches} switches</span>`;
|
||||
}
|
||||
|
||||
function updatePlanningCoach(coach) {
|
||||
const statusEl = document.getElementById('coachStatus');
|
||||
if (!statusEl) return;
|
||||
if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; }
|
||||
if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; }
|
||||
if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; }
|
||||
if (coach.status === 'ready' && coach.proposal) {
|
||||
statusEl.textContent = 'AI suggestion applied — edit freely.';
|
||||
const stamp = JSON.stringify(coach.proposal);
|
||||
if (statusEl.dataset.applied === stamp) return;
|
||||
statusEl.dataset.applied = stamp;
|
||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||
if (na && !na.value.trim()) na.value = coach.proposal.next_action || '';
|
||||
if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
|
||||
if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
|
||||
if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
|
||||
const apps = document.getElementById('apps');
|
||||
if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
|
||||
apps.value = coach.proposal.allowed_window_classes.join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
setStateAttr(state);
|
||||
const rs = state.runtime_state;
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
updatePlanningCoach(state.coach);
|
||||
return;
|
||||
}
|
||||
if (rs === 'active' && renderedState === 'active') {
|
||||
updateActiveDrift(state);
|
||||
return;
|
||||
}
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||
renderedState = rs;
|
||||
|
||||
if (rs === 'locked') {
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Locked</span></div>
|
||||
<div class="band">
|
||||
<p class="meta">No active commitment.</p>
|
||||
<button id="plan" class="btn btn-primary">Start planning</button>
|
||||
</div>`;
|
||||
document.getElementById('plan').onclick = () => post('/planning');
|
||||
|
||||
} else if (rs === 'planning') {
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
||||
<div class="band">
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
</div>
|
||||
<div class="band">
|
||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
||||
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
||||
<label>Allowed apps (comma-separated window classes)</label>
|
||||
<input id="apps" placeholder="e.g. code, firefox">
|
||||
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
||||
</div>`;
|
||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
||||
[na, sc, mins].forEach(el => el.oninput = check);
|
||||
start.onclick = () => post('/commitment', {
|
||||
next_action: na.value.trim(),
|
||||
success_condition: sc.value.trim(),
|
||||
timebox_secs: Math.round(+mins.value * 60),
|
||||
allowed_window_classes: (document.getElementById('apps').value || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean),
|
||||
});
|
||||
document.getElementById('sharpen').onclick = () => {
|
||||
const intent = document.getElementById('intent').value.trim();
|
||||
if (intent) post('/coach', { intent });
|
||||
};
|
||||
updatePlanningCoach(state.coach);
|
||||
|
||||
} else if (rs === 'active') {
|
||||
const c = state.commitment || {};
|
||||
view.innerHTML = `<div class="band statusband" id="statusband"></div>
|
||||
<div class="band"><div class="timer" id="t">--:--</div></div>
|
||||
<div class="band">
|
||||
<div class="action">${c.next_action || ''}</div>
|
||||
<p class="meta">done when: ${c.success_condition || ''}</p>
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
|
||||
document.getElementById('done').onclick = () => post('/complete');
|
||||
const t = document.getElementById('t');
|
||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 250);
|
||||
updateActiveDrift(state);
|
||||
|
||||
} else if (rs === 'review') {
|
||||
const c = state.commitment || {};
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
||||
<div class="band">
|
||||
<div class="action">Session ended</div>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
document.getElementById('end').onclick = () => post('/end');
|
||||
|
||||
} else {
|
||||
view.textContent = rs;
|
||||
}
|
||||
}
|
||||
|
||||
const es = new EventSource('/events');
|
||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the web suite (regression guard)**
|
||||
|
||||
Run: `go test ./internal/web/ -v`
|
||||
Expected: PASS — all endpoint/asset tests still green (markup changed, contracts did not).
|
||||
|
||||
- [ ] **Step 4: Manual visual check**
|
||||
|
||||
Run the daemon and click through states:
|
||||
|
||||
```bash
|
||||
go run ./cmd/antidriftd
|
||||
```
|
||||
|
||||
Verify in the browser (http://localhost:7777):
|
||||
- Locked: gray accent, "Start planning".
|
||||
- Planning: blue accent, all fields, Sharpen + Start.
|
||||
- Active on-task: green accent, "Active · on task · N switches", big timer, evidence band.
|
||||
- Drift (force by setting allowed apps then switching to an off-list window): red accent, reason + three buttons.
|
||||
- Nudge (semantic nudge within an allowed app): amber accent, message + Dismiss; after Dismiss the accent returns to green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.css internal/web/static/app.js
|
||||
git commit -m "M4: cockpit HUD with state-driven accent
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Polish the review screen into a session recap
|
||||
|
||||
Turn the bare review state into a presentational summary built from data the state already carries (`commitment` + `evidence`). No new backend data.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.js` (add `reviewSummary` helper; swap the review branch's markup)
|
||||
- Modify: `internal/web/static/app.css` (`.summary` styles already added in Task 2 — verify, no change expected)
|
||||
- Test: existing `internal/web/web_test.go` (regression only)
|
||||
|
||||
- [ ] **Step 1: Add the `reviewSummary` helper to `app.js`**
|
||||
|
||||
Insert this function immediately after `evidenceBlock` in `internal/web/static/app.js`:
|
||||
|
||||
```js
|
||||
// reviewSummary renders a presentational recap from already-available state:
|
||||
// the commitment plus the per-window evidence buckets. No new backend data.
|
||||
function reviewSummary(ev) {
|
||||
if (!ev) return '';
|
||||
const rows = (ev.buckets || []).map(b =>
|
||||
`<div class="summary-row"><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></div>`).join('');
|
||||
const switches = `<div class="summary-row"><span>context switches</span><span>${ev.switch_count || 0}</span></div>`;
|
||||
return `<div class="band summary">${switches}${rows}</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap the review branch markup to use the recap**
|
||||
|
||||
In `internal/web/static/app.js`, find the review branch (the `else if (rs === 'review')` block) and replace its `view.innerHTML = …` assignment. Change FROM:
|
||||
|
||||
```js
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
||||
<div class="band">
|
||||
<div class="action">Session ended</div>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
```
|
||||
|
||||
TO:
|
||||
|
||||
```js
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
|
||||
<div class="band">
|
||||
<div class="action">Session ended</div>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
<p class="meta">done when: ${c.success_condition || ''}</p>
|
||||
</div>
|
||||
${reviewSummary(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
```
|
||||
|
||||
(The verbose live `evidenceBlock` is replaced by the calmer `reviewSummary` recap; the "now / tracking" line belongs to the active HUD, not the post-session summary.)
|
||||
|
||||
- [ ] **Step 3: Run the web suite (regression guard)**
|
||||
|
||||
Run: `go test ./internal/web/ -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manual visual check of review**
|
||||
|
||||
Start a short commitment (1 minute) with allowed apps, switch between a couple of windows, let it expire (or click Complete), and confirm the review screen shows: violet accent, "Session ended", the action + success condition, then a summary band listing context switches and the per-window time buckets, then End.
|
||||
|
||||
- [ ] **Step 5: Final module verification**
|
||||
|
||||
Run: `go vet ./... && go test -race ./...`
|
||||
Expected: clean, all green.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.js internal/web/static/app.css
|
||||
git commit -m "M4: presentational review recap
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## After all tasks
|
||||
|
||||
Dispatch a final code reviewer over the three commits, then use **superpowers:finishing-a-development-branch** to close out M4.
|
||||
|
||||
Done when: the UI renders the cockpit HUD across all six `data-state` values, CSS/JS are served as separate embedded assets, the review screen shows a recap, and `go vet ./... && go test -race ./...` is clean.
|
||||
@@ -1,794 +0,0 @@
|
||||
# M5 — Tasks Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `tasks.Provider` port with an Amazing Marvin adapter (`am --json`) that lists today's open tasks; surface them on the planning screen as clickable chips that seed the intent field.
|
||||
|
||||
**Architecture:** A new leaf package `internal/tasks` (interface + primitives only, like `ai`) with a CLI adapter that shells out to ampy's `am --json`. The `session.Controller` fetches the list asynchronously on entering planning — mirroring the existing planning coach (generation counter, status enum, graceful degradation) — and projects it into the broadcast `State`. The browser renders the list during planning; clicking a task fills `#intent`, which flows into the unchanged coach pipeline. Read-only; no writeback; no new endpoints.
|
||||
|
||||
**Tech Stack:** Go 1.26, stdlib `os/exec` + `encoding/json`, Gin + SSE (unchanged), vanilla JS/CSS, stdlib `testing`.
|
||||
|
||||
**Reference:** Design spec at `docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md`. The patterns being mirrored: the `ai` port (`internal/ai/coach.go`, `internal/ai/backend.go`) and the planning coach in `internal/session/session.go` (`RequestCoach`, `resetCoachLocked`, `CoachView`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create `internal/tasks/tasks.go`** — the `Provider` interface and the `Task` value type. Leaf package; imports only `context`.
|
||||
- **Create `internal/tasks/marvin.go`** — the `Marvin` adapter (shells out to `am --json`), an injectable `runner`, and the pure `parse` function.
|
||||
- **Create `internal/tasks/marvin_test.go`** — `parse` table tests, command-splitting test, and adapter tests with a fake runner.
|
||||
- **Modify `internal/session/session.go`** — add tasks fields, `TaskView`/`TasksView`, `State.Tasks`, `SetTasks`, `startTasksFetchLocked`, the planning-only projection, and the `EnterPlanning` hook.
|
||||
- **Modify `internal/session/session_test.go`** — `fakeProvider`, `waitTasksStatus`, and four controller tests.
|
||||
- **Modify `cmd/antidriftd/main.go`** — construct the Marvin adapter and call `ctrl.SetTasks`.
|
||||
- **Modify `internal/web/web_test.go`** — `stubProvider` and a planning-payload-carries-tasks test.
|
||||
- **Modify `internal/web/static/app.js`** — `updatePlanningTasks`, a tasks band in the planning render, and calls from both render paths.
|
||||
- **Modify `internal/web/static/app.css`** — `.tasklist` / `.task-chip` styling.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `tasks` package (port + Marvin adapter)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/tasks/tasks.go`
|
||||
- Create: `internal/tasks/marvin.go`
|
||||
- Test: `internal/tasks/marvin_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the port interface and value type**
|
||||
|
||||
Create `internal/tasks/tasks.go`:
|
||||
|
||||
```go
|
||||
// Package tasks is the Tasks port: it answers "what should I be doing?" by
|
||||
// listing the open to-do items due today or earlier. It imports nothing from
|
||||
// the rest of the app, so it stays a leaf package.
|
||||
package tasks
|
||||
|
||||
import "context"
|
||||
|
||||
// Task is one to-do item. Primitives only, so tasks stays a leaf package.
|
||||
type Task struct {
|
||||
ID string
|
||||
Title string
|
||||
Day string // "YYYY-MM-DD", or "" if unscheduled
|
||||
}
|
||||
|
||||
// Provider answers "what should I be doing?" — the open tasks due today or
|
||||
// earlier.
|
||||
type Provider interface {
|
||||
Today(ctx context.Context) ([]Task, error)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing adapter tests**
|
||||
|
||||
Create `internal/tasks/marvin_test.go`:
|
||||
|
||||
```go
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want []Task
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid array",
|
||||
in: `[{"id":"a","title":"Write spec","parentId":"p","day":"2026-05-31","done":false}]`,
|
||||
want: []Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}},
|
||||
},
|
||||
{name: "empty array", in: `[]`, want: []Task{}},
|
||||
{name: "json null", in: `null`, want: []Task{}},
|
||||
{
|
||||
name: "drops done and empty-title",
|
||||
in: `[{"id":"a","title":"keep","day":"","done":false},{"id":"b","title":"done one","done":true},{"id":"c","title":" ","done":false}]`,
|
||||
want: []Task{{ID: "a", Title: "keep", Day: ""}},
|
||||
},
|
||||
{name: "malformed", in: `not json`, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parse([]byte(tc.in))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d (%+v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("[%d] = %+v, want %+v", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMarvinCommandSplitting(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantCmd string
|
||||
wantArgs []string
|
||||
}{
|
||||
{"", "am", []string{"--json"}},
|
||||
{"am", "am", []string{"--json"}},
|
||||
{"uv run am", "uv", []string{"run", "am", "--json"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m := NewMarvin(tc.in)
|
||||
if m.cmd != tc.wantCmd {
|
||||
t.Errorf("NewMarvin(%q).cmd = %q, want %q", tc.in, m.cmd, tc.wantCmd)
|
||||
}
|
||||
if len(m.args) != len(tc.wantArgs) {
|
||||
t.Fatalf("NewMarvin(%q).args = %v, want %v", tc.in, m.args, tc.wantArgs)
|
||||
}
|
||||
for i := range m.args {
|
||||
if m.args[i] != tc.wantArgs[i] {
|
||||
t.Errorf("NewMarvin(%q).args[%d] = %q, want %q", tc.in, i, m.args[i], tc.wantArgs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayUsesRunnerOutput(t *testing.T) {
|
||||
m := NewMarvin("am")
|
||||
var gotName string
|
||||
var gotArgs []string
|
||||
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
gotName, gotArgs = name, args
|
||||
return []byte(`[{"id":"x","title":"Do thing","day":"2026-05-31","done":false}]`), nil
|
||||
}
|
||||
got, err := m.Today(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Today: %v", err)
|
||||
}
|
||||
if gotName != "am" || len(gotArgs) != 1 || gotArgs[0] != "--json" {
|
||||
t.Fatalf("runner called with %q %v", gotName, gotArgs)
|
||||
}
|
||||
if len(got) != 1 || got[0].Title != "Do thing" {
|
||||
t.Fatalf("Today result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPropagatesError(t *testing.T) {
|
||||
m := NewMarvin("am")
|
||||
m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
return nil, errors.New("am not found")
|
||||
}
|
||||
if _, err := m.Today(context.Background()); err == nil {
|
||||
t.Fatal("want error from failing runner")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/tasks/ -v`
|
||||
Expected: FAIL — `undefined: NewMarvin`, `undefined: parse` (build error).
|
||||
|
||||
- [ ] **Step 4: Write the adapter implementation**
|
||||
|
||||
Create `internal/tasks/marvin.go`:
|
||||
|
||||
```go
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runner executes a command and returns its stdout. Production shells out;
|
||||
// tests inject a fake to avoid spawning a process.
|
||||
type runner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
if s := strings.TrimSpace(errb.String()); s != "" {
|
||||
return nil, fmt.Errorf("%s: %w: %s", name, err, s)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// Marvin is the Amazing Marvin adapter. It shells out to ampy's CLI
|
||||
// (`am --json`, no subcommand) and parses the open tasks due today or earlier.
|
||||
type Marvin struct {
|
||||
cmd string
|
||||
args []string
|
||||
run runner
|
||||
}
|
||||
|
||||
// NewMarvin builds the adapter. command is split on spaces so both "am" and
|
||||
// "uv run am" work; empty defaults to "am". The "--json" flag is always
|
||||
// appended, and no subcommand is passed (ampy lists today's tasks by default).
|
||||
func NewMarvin(command string) *Marvin {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"am"}
|
||||
}
|
||||
args := append([]string{}, fields[1:]...)
|
||||
args = append(args, "--json")
|
||||
return &Marvin{cmd: fields[0], args: args, run: execRunner}
|
||||
}
|
||||
|
||||
// Today returns the open tasks due today or earlier, or an error if the CLI
|
||||
// fails. Callers degrade gracefully on error.
|
||||
func (m *Marvin) Today(ctx context.Context) ([]Task, error) {
|
||||
out, err := m.run(ctx, m.cmd, m.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parse(out)
|
||||
}
|
||||
|
||||
type rawTask struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Day string `json:"day"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// parse maps `am --json` output (a JSON array of task objects) to []Task. It
|
||||
// drops tasks that are done or have an empty title. A JSON null or empty array
|
||||
// yields an empty slice, not an error.
|
||||
func parse(data []byte) ([]Task, error) {
|
||||
var raw []rawTask
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("tasks: parse: %w", err)
|
||||
}
|
||||
out := make([]Task, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
title := strings.TrimSpace(r.Title)
|
||||
if r.Done || title == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Task{ID: r.ID, Title: title, Day: r.Day})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/tasks/ -v`
|
||||
Expected: PASS — `TestParse`, `TestNewMarvinCommandSplitting`, `TestTodayUsesRunnerOutput`, `TestTodayPropagatesError`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/tasks/tasks.go internal/tasks/marvin.go internal/tasks/marvin_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Add tasks port and Amazing Marvin adapter
|
||||
|
||||
The tasks.Provider port answers "what should I be doing?". The Marvin
|
||||
adapter shells out to ampy's `am --json` and parses today's open tasks,
|
||||
dropping done/empty-title entries. Leaf package mirroring ai.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Controller wiring (async fetch + projection)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session.go`
|
||||
- Test: `internal/session/session_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing controller tests**
|
||||
|
||||
Append to `internal/session/session_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add:
|
||||
|
||||
```go
|
||||
type fakeProvider struct {
|
||||
list []tasks.Task
|
||||
err error
|
||||
gate chan struct{} // if non-nil, Today blocks until it receives
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.list, f.err
|
||||
}
|
||||
|
||||
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
|
||||
func waitTasksStatus(t *testing.T, c *Controller, want string) State {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := c.State()
|
||||
if st.Tasks != nil && st.Tasks.Status == want {
|
||||
return st
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks)
|
||||
return State{}
|
||||
}
|
||||
|
||||
func TestEnterPlanningFetchesTasks(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}})
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("enter planning: %v", err)
|
||||
}
|
||||
st := waitTasksStatus(t, c, "ready")
|
||||
if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" {
|
||||
t.Fatalf("tasks list wrong: %+v", st.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksFetchError(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetTasks(&fakeProvider{err: errors.New("am down")})
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("enter planning: %v", err)
|
||||
}
|
||||
st := waitTasksStatus(t, c, "error")
|
||||
if len(st.Tasks.Tasks) != 0 {
|
||||
t.Fatalf("error state should carry no tasks: %+v", st.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProviderNoTasksView(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("enter planning: %v", err)
|
||||
}
|
||||
if c.State().Tasks != nil {
|
||||
t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksViewAbsentOutsidePlanning(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}})
|
||||
if c.State().Tasks != nil {
|
||||
t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v`
|
||||
Expected: FAIL — `c.SetTasks undefined` and `st.Tasks undefined` (build error).
|
||||
|
||||
- [ ] **Step 3: Add the import and constants**
|
||||
|
||||
In `internal/session/session.go`, add `"antidrift/internal/tasks"` to the import block (it already imports `"antidrift/internal/ai"`).
|
||||
|
||||
Immediately after the existing coach constants block (the `const ( coachIdle = "idle" … )` group near the top of the file), add:
|
||||
|
||||
```go
|
||||
const tasksTimeout = 30 * time.Second
|
||||
|
||||
const (
|
||||
tasksIdle = "idle"
|
||||
tasksPending = "pending"
|
||||
tasksReady = "ready"
|
||||
tasksError = "error"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the controller fields**
|
||||
|
||||
In the `Controller` struct, immediately after the coach fields (`coachGen int`), add:
|
||||
|
||||
```go
|
||||
tasksProvider tasks.Provider
|
||||
tasksStatus string
|
||||
tasksList []tasks.Task
|
||||
tasksGen int
|
||||
```
|
||||
|
||||
(Field is named `tasksProvider`, not `tasks`, so it does not shadow the `tasks` package.)
|
||||
|
||||
- [ ] **Step 5: Add the view types**
|
||||
|
||||
Immediately after the `CoachView` struct definition, add:
|
||||
|
||||
```go
|
||||
// TaskView is one to-do item in the planning tasks list.
|
||||
type TaskView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Day string `json:"day,omitempty"`
|
||||
}
|
||||
|
||||
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
|
||||
type TasksView struct {
|
||||
Status string `json:"status"`
|
||||
Tasks []TaskView `json:"tasks,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
In the `State` struct, add a field immediately after `Coach *CoachView … `:
|
||||
|
||||
```go
|
||||
Tasks *TasksView `json:"tasks,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the planning-only projection**
|
||||
|
||||
In `stateLocked`, inside the existing `if c.runtimeState == domain.RuntimePlanning {` block, immediately after `st.Coach = cv`, add:
|
||||
|
||||
```go
|
||||
if c.tasksProvider != nil {
|
||||
tstatus := c.tasksStatus
|
||||
if tstatus == "" {
|
||||
tstatus = tasksIdle
|
||||
}
|
||||
tv := &TasksView{Status: tstatus}
|
||||
for _, t := range c.tasksList {
|
||||
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
|
||||
}
|
||||
st.Tasks = tv
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add SetTasks and the async fetch**
|
||||
|
||||
Immediately after `resetCoachLocked` (so the tasks helpers sit next to the coach helpers), add:
|
||||
|
||||
```go
|
||||
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
|
||||
// the planning tasks list absent.
|
||||
func (c *Controller) SetTasks(p tasks.Provider) {
|
||||
c.mu.Lock()
|
||||
c.tasksProvider = p
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
|
||||
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
|
||||
// post-planning results, and notifies on completion. Caller holds mu.
|
||||
func (c *Controller) startTasksFetchLocked() {
|
||||
c.tasksList = nil
|
||||
if c.tasksProvider == nil {
|
||||
c.tasksStatus = tasksIdle
|
||||
return
|
||||
}
|
||||
c.tasksGen++
|
||||
gen := c.tasksGen
|
||||
c.tasksStatus = tasksPending
|
||||
provider := c.tasksProvider
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout)
|
||||
defer cancel()
|
||||
list, err := provider.Today(ctx)
|
||||
|
||||
c.mu.Lock()
|
||||
if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning {
|
||||
c.mu.Unlock()
|
||||
return // stale or left planning: discard
|
||||
}
|
||||
if err != nil {
|
||||
c.tasksStatus = tasksError
|
||||
c.tasksList = nil
|
||||
} else {
|
||||
c.tasksStatus = tasksReady
|
||||
c.tasksList = list
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
(`context` is already imported in `session.go`.)
|
||||
|
||||
- [ ] **Step 8: Hook the fetch into EnterPlanning**
|
||||
|
||||
In `EnterPlanning`, add `c.startTasksFetchLocked()` immediately after the existing `c.resetCoachLocked()` line:
|
||||
|
||||
```go
|
||||
c.runtimeState = next
|
||||
c.resetCoachLocked()
|
||||
c.startTasksFetchLocked()
|
||||
return c.persistLocked()
|
||||
```
|
||||
|
||||
(Launching the goroutine while holding `mu` is safe: its first action is the `Today()` call, so it only blocks on the lock after `EnterPlanning` has returned and released it.)
|
||||
|
||||
- [ ] **Step 9: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v`
|
||||
Expected: PASS — `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`.
|
||||
|
||||
- [ ] **Step 10: Run the full session suite (no regressions, race-clean)**
|
||||
|
||||
Run: `go test -race ./internal/session/`
|
||||
Expected: PASS (existing coach/drift/nudge tests unaffected — `EnterPlanning` with no provider just sets the idle status).
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session.go internal/session/session_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Fetch today's tasks asynchronously on entering planning
|
||||
|
||||
The controller fetches the Marvin task list when entering planning,
|
||||
mirroring the planning coach: generation-guarded goroutine, status enum
|
||||
(idle/pending/ready/error), projected into State only while planning and
|
||||
only when a provider is set. nil provider degrades to no tasks view.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Daemon wiring + web payload test
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/antidriftd/main.go`
|
||||
- Test: `internal/web/web_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing web test**
|
||||
|
||||
Append to `internal/web/web_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add:
|
||||
|
||||
```go
|
||||
type stubProvider struct {
|
||||
list []tasks.Task
|
||||
}
|
||||
|
||||
func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
||||
return s.list, nil
|
||||
}
|
||||
|
||||
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"tasks"`) {
|
||||
t.Fatalf("planning payload missing tasks object: %s", js)
|
||||
}
|
||||
if !strings.Contains(js, `"title":"Write the spec"`) {
|
||||
t.Fatalf("planning payload missing task title: %s", js)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v`
|
||||
Expected: FAIL — `undefined: tasks` / `s.ctrl.SetTasks undefined` until the import resolves and the controller exposes the method (the method exists from Task 2, so the only failure here would be a missing import; if Task 2 is complete this compiles and the assertion drives correctness).
|
||||
|
||||
- [ ] **Step 3: Wire the adapter into the daemon**
|
||||
|
||||
In `cmd/antidriftd/main.go`, add `"antidrift/internal/tasks"` to the import block. Then, immediately after the AI-wiring block (after the `if backend, err := ai.NewBackend(...) { … } else { … }` block closes), add:
|
||||
|
||||
```go
|
||||
// Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is
|
||||
// overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute
|
||||
// path). A missing or failing CLI degrades to no tasks panel at fetch time —
|
||||
// planning still works.
|
||||
ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD")))
|
||||
log.Printf("tasks: marvin adapter (am)")
|
||||
```
|
||||
|
||||
(`os` and `log` are already imported in `main.go`.)
|
||||
|
||||
- [ ] **Step 4: Run the web test to verify it passes**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Verify the daemon builds**
|
||||
|
||||
Run: `go build ./cmd/antidriftd/`
|
||||
Expected: builds with no output.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/antidriftd/main.go internal/web/web_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Wire Marvin tasks adapter into the daemon
|
||||
|
||||
main constructs the Marvin adapter (command overridable via
|
||||
ANTIDRIFT_MARVIN_CMD) and injects it. Adds a web test asserting the
|
||||
planning state payload carries today's tasks.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Planning UI — today's tasks as seed chips
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.js`
|
||||
- Modify: `internal/web/static/app.css`
|
||||
|
||||
This task is presentational; the data path is already covered by the Task 3 web test. There is no JS test harness in this project (consistent with M4), so verify by build/test plus the manual checklist below.
|
||||
|
||||
- [ ] **Step 1: Add the `updatePlanningTasks` function**
|
||||
|
||||
In `internal/web/static/app.js`, immediately after the `updatePlanningCoach` function definition (before `function render(state)`), add:
|
||||
|
||||
```js
|
||||
// updatePlanningTasks renders today's Marvin tasks as clickable seed chips.
|
||||
// Clicking a chip fills the intent field; the coach pipeline is unchanged.
|
||||
// idle/error/empty render nothing; pending shows a quiet loading line.
|
||||
function updatePlanningTasks(tasks) {
|
||||
const el = document.getElementById('tasksBand');
|
||||
if (!el) return;
|
||||
if (!tasks || tasks.status === 'idle' || tasks.status === 'error') { el.innerHTML = ''; return; }
|
||||
if (tasks.status === 'pending') { el.innerHTML = `<div class="meta">loading tasks…</div>`; return; }
|
||||
const list = tasks.tasks || [];
|
||||
if (!list.length) { el.innerHTML = ''; return; }
|
||||
const chips = list.map((t, i) =>
|
||||
`<button type="button" class="task-chip" data-i="${i}">${t.title}</button>`).join('');
|
||||
el.innerHTML = `<label>Today</label><div class="tasklist">${chips}</div>`;
|
||||
el.querySelectorAll('.task-chip').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const intent = document.getElementById('intent');
|
||||
if (intent) { intent.value = list[+btn.dataset.i].title; intent.focus(); }
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
(Task titles are interpolated into `innerHTML` exactly as the existing coach/evidence rendering does. The titles originate from the user's own Marvin database; this carries the same pre-existing self-XSS posture as the rest of the UI and introduces no new untrusted source — out of scope to change here.)
|
||||
|
||||
- [ ] **Step 2: Add the tasks band to the planning render**
|
||||
|
||||
In the planning branch of `render` (`} else if (rs === 'planning') {`), add a tasks band placeholder between the intent band and the "Next action" band. Change:
|
||||
|
||||
```js
|
||||
<div class="band">
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
</div>
|
||||
<div class="band">
|
||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
<div class="band">
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
</div>
|
||||
<div class="band" id="tasksBand"></div>
|
||||
<div class="band">
|
||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call `updatePlanningTasks` from both render paths**
|
||||
|
||||
In the planning partial-update early-return at the top of `render`, change:
|
||||
|
||||
```js
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
updatePlanningCoach(state.coach);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
updatePlanningCoach(state.coach);
|
||||
updatePlanningTasks(state.tasks);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
And at the end of the full planning render branch, change the trailing:
|
||||
|
||||
```js
|
||||
updatePlanningCoach(state.coach);
|
||||
|
||||
} else if (rs === 'active') {
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
updatePlanningCoach(state.coach);
|
||||
updatePlanningTasks(state.tasks);
|
||||
|
||||
} else if (rs === 'active') {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the chip styling**
|
||||
|
||||
Append to `internal/web/static/app.css`:
|
||||
|
||||
```css
|
||||
/* Planning: today's tasks as clickable seed chips */
|
||||
.tasklist { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.task-chip {
|
||||
padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
|
||||
background: var(--bg); color: var(--ink); font: inherit; font-size: 13px;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.task-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify assets still serve and the full suite is race-clean**
|
||||
|
||||
Run: `go vet ./... && go test -race ./...`
|
||||
Expected: PASS across all packages (the embedded-asset and planning-payload tests cover the wiring; JS/CSS are presentational).
|
||||
|
||||
- [ ] **Step 6: Manual visual check (by eye)**
|
||||
|
||||
Build and run the daemon (`go run ./cmd/antidriftd/`) with `am` available, click **Start planning**, and confirm: a "Today" band shows task chips; clicking a chip fills the intent field; **Sharpen** then drives the coach as before. With `am` absent, the band stays empty and planning works normally.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.js internal/web/static/app.css
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Show today's tasks as seed chips on the planning screen
|
||||
|
||||
The planning view renders Marvin's today list as clickable chips;
|
||||
clicking one fills the intent field, feeding the existing coach flow.
|
||||
idle/error/empty render nothing, pending shows a quiet loading line.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
After all tasks:
|
||||
|
||||
- [ ] `go vet ./... && go test -race ./...` is clean.
|
||||
- [ ] `internal/tasks` imports only `context`, `encoding/json`, and stdlib `os/exec`/`bytes`/`fmt`/`strings` — nothing from `domain`, `session`, `evidence`, or `web` (leaf package preserved).
|
||||
- [ ] No new POST routes; the seed click is client-only.
|
||||
- [ ] No writeback to Marvin (read-only, per spec §7).
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,796 +0,0 @@
|
||||
# M8 (Tier A) — Window-minimize Enforcement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When a session opts into enforcement and the drift judge confirms the active window is off-task, minimize that window — activating the dormant `domain.EnforcementLevel` and establishing the unprivileged `enforce.Guard` port.
|
||||
|
||||
**Architecture:** A new leaf port `enforce.Guard` (`MinimizeActive(ctx)`), with an X11 adapter (native `jezek/xgbutil`, no `xdotool`) and a no-op adapter, mirroring `evidence.Source`. The `session.Controller` owns all policy: it threads a per-commitment `EnforcementLevel` (persisted in the snapshot), and on every confirmed-drift observation at the `block` level it runs the guard's minimize off-lock — the established async-I/O discipline. The browser shows a planning toggle and a drift-band note.
|
||||
|
||||
**Tech Stack:** Go 1.26, stdlib + `github.com/jezek/xgbutil` (already a dependency), Gin, vanilla JS/CSS, stdlib `testing`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New files**
|
||||
- `internal/enforce/enforce.go` — the `Guard` interface (no build tag). One responsibility: define the port.
|
||||
- `internal/enforce/guard_other.go` (`//go:build !linux`) — no-op `NewGuard`.
|
||||
- `internal/enforce/x11.go` (`//go:build linux`) — real `NewGuard` using xgbutil.
|
||||
- `internal/enforce/enforce_test.go` — portable unit test (compiles on all platforms).
|
||||
- `internal/enforce/x11_integration_test.go` (`//go:build linux`) — live X11 smoke test, skipped without `DISPLAY`.
|
||||
|
||||
**Modified files**
|
||||
- `internal/store/store.go` — `Snapshot` gains `EnforcementLevel`.
|
||||
- `internal/session/session.go` — `EnforcementLevel` field, signature change, persistence, the `enforce` hook, `DriftView.Enforced`.
|
||||
- `internal/session/session_test.go` — call-site updates, `fakeGuard`, enforcement tests.
|
||||
- `internal/web/web.go` — `commitmentRequest.Enforce` → level mapping.
|
||||
- `internal/web/web_test.go` — `stubJudge` + enforced-on-the-wire test.
|
||||
- `cmd/antidriftd/main.go` — `ctrl.SetGuard(enforce.NewGuard())`.
|
||||
- `internal/web/static/app.js` — planning toggle + drift-band note.
|
||||
- `internal/web/static/app.css` — note/toggle styling.
|
||||
- `README.md` — M8 paragraph.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: The `enforce` port and its adapters
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/enforce/enforce.go`
|
||||
- Create: `internal/enforce/guard_other.go`
|
||||
- Create: `internal/enforce/x11.go`
|
||||
- Create: `internal/enforce/enforce_test.go`
|
||||
- Create: `internal/enforce/x11_integration_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `internal/enforce/enforce_test.go` (no build tag — must compile on every platform):
|
||||
|
||||
```go
|
||||
package enforce
|
||||
|
||||
import "testing"
|
||||
|
||||
// NewGuard must return a usable Guard on every platform (real on linux, no-op
|
||||
// elsewhere). We assert non-nil only: calling MinimizeActive here would touch a
|
||||
// real X server on linux, which the integration test covers under a DISPLAY
|
||||
// guard. The behavioural contract is exercised in the session package via a fake
|
||||
// Guard.
|
||||
func TestNewGuardReturnsUsableGuard(t *testing.T) {
|
||||
if g := NewGuard(); g == nil {
|
||||
t.Fatal("NewGuard returned nil")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/enforce/`
|
||||
Expected: FAIL — build error, `undefined: NewGuard` / package has no Go files yet.
|
||||
|
||||
- [ ] **Step 3: Write the interface**
|
||||
|
||||
Create `internal/enforce/enforce.go`:
|
||||
|
||||
```go
|
||||
// Package enforce makes drift cost something at the OS level. Tier A minimizes
|
||||
// the active window when the session is enforcing and the drift judge has
|
||||
// confirmed the window is off-task. The Guard is a pure OS primitive; all
|
||||
// policy — whether and when to enforce — lives in the session controller.
|
||||
package enforce
|
||||
|
||||
import "context"
|
||||
|
||||
// Guard performs OS-level enforcement actions on demand.
|
||||
type Guard interface {
|
||||
// MinimizeActive minimizes the currently-focused window. It is idempotent
|
||||
// (minimizing an already-minimized window is harmless) and best-effort: it
|
||||
// returns an error for diagnostics, but callers never block on it and treat
|
||||
// failure as "enforcement did nothing this time."
|
||||
MinimizeActive(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the no-op adapter**
|
||||
|
||||
Create `internal/enforce/guard_other.go`:
|
||||
|
||||
```go
|
||||
//go:build !linux
|
||||
|
||||
package enforce
|
||||
|
||||
import "context"
|
||||
|
||||
// NewGuard returns a no-op guard on platforms without the X11 adapter.
|
||||
func NewGuard() Guard { return noopGuard{} }
|
||||
|
||||
type noopGuard struct{}
|
||||
|
||||
func (noopGuard) MinimizeActive(context.Context) error { return nil }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write the X11 adapter**
|
||||
|
||||
Create `internal/enforce/x11.go`. The minimize is the standard ICCCM iconify: a `WM_CHANGE_STATE` client message carrying `IconicState` (`icccm.StateIconic`, value 3) sent to the root window, which `ewmh.ClientEvent` does (it targets the root with SubstructureNotify|Redirect):
|
||||
|
||||
```go
|
||||
//go:build linux
|
||||
|
||||
package enforce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jezek/xgbutil"
|
||||
"github.com/jezek/xgbutil/ewmh"
|
||||
"github.com/jezek/xgbutil/icccm"
|
||||
)
|
||||
|
||||
// NewGuard returns the real X11 window-minimize guard.
|
||||
func NewGuard() Guard { return x11Guard{} }
|
||||
|
||||
type x11Guard struct{}
|
||||
|
||||
// MinimizeActive iconifies the currently-focused window by sending an ICCCM
|
||||
// WM_CHANGE_STATE -> IconicState client message to the root window. It opens a
|
||||
// short-lived X connection per call: enforcement fires at most once per drift
|
||||
// observation (which is debounce-gated upstream), so there is no shared
|
||||
// connection or event loop to manage, and nothing to race on shutdown. Any X
|
||||
// failure is returned for the caller to log; the caller never blocks on it.
|
||||
func (x11Guard) MinimizeActive(_ context.Context) error {
|
||||
X, err := xgbutil.NewConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("enforce: cannot connect to X server: %w", err)
|
||||
}
|
||||
defer X.Conn().Close()
|
||||
|
||||
active, err := ewmh.ActiveWindowGet(X)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enforce: no active window: %w", err)
|
||||
}
|
||||
if active == 0 {
|
||||
return nil // nothing focused; nothing to minimize
|
||||
}
|
||||
if err := ewmh.ClientEvent(X, active, "WM_CHANGE_STATE", int(icccm.StateIconic)); err != nil {
|
||||
return fmt.Errorf("enforce: minimize request failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write the live X11 integration test**
|
||||
|
||||
Create `internal/enforce/x11_integration_test.go` (mirrors `internal/evidence/x11_integration_test.go`):
|
||||
|
||||
```go
|
||||
//go:build linux
|
||||
|
||||
package enforce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestX11GuardMinimizeActiveDoesNotPanic(t *testing.T) {
|
||||
if os.Getenv("DISPLAY") == "" {
|
||||
t.Skip("no DISPLAY; skipping live X11 minimize smoke test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
// Either it minimizes the active window or returns an error (e.g. no active
|
||||
// window); we only assert it returns without panicking.
|
||||
if err := NewGuard().MinimizeActive(ctx); err != nil {
|
||||
t.Logf("MinimizeActive returned (acceptable): %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/enforce/`
|
||||
Expected: PASS (`TestNewGuardReturnsUsableGuard` passes; the integration test passes or skips depending on `DISPLAY`).
|
||||
|
||||
- [ ] **Step 8: Build all platforms compile**
|
||||
|
||||
Run: `go vet ./internal/enforce/ && GOOS=darwin go build ./internal/enforce/`
|
||||
Expected: no output (both the linux and non-linux files compile).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/enforce/
|
||||
git commit -m "Add enforce.Guard port with X11 and no-op adapters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Activate the per-commitment EnforcementLevel (signature, field, persistence)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/store/store.go:16-28` (Snapshot struct)
|
||||
- Modify: `internal/session/session.go` (Controller field, `New`, `persistLocked`, `StartManualCommitment`, a test accessor)
|
||||
- Modify: `internal/web/web.go:108-122` (request field + level mapping)
|
||||
- Modify: `internal/session/session_test.go` (call-site updates + persistence test)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `internal/session/session_test.go` (`domain`, `filepath`, `time` are already imported):
|
||||
|
||||
```go
|
||||
func TestEnforcementLevelPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
first, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.SetClock(func() time.Time { return time.Unix(1000, 0) })
|
||||
if err := first.EnterPlanning(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementBlock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := second.EnforcementLevelForTest(); got != domain.EnforcementBlock {
|
||||
t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/session/ -run TestEnforcementLevelPersists`
|
||||
Expected: FAIL — build error: `StartManualCommitment` wants 4 args, and `EnforcementLevelForTest` is undefined.
|
||||
|
||||
- [ ] **Step 3: Add the snapshot field**
|
||||
|
||||
In `internal/store/store.go`, add to the `Snapshot` struct (after the `CarryForward` line):
|
||||
|
||||
```go
|
||||
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
|
||||
```
|
||||
|
||||
(`domain` is already imported in `store.go`.)
|
||||
|
||||
- [ ] **Step 4: Add the Controller field**
|
||||
|
||||
In `internal/session/session.go`, in the `Controller` struct, add next to `allowedClasses` (the other durable per-session field):
|
||||
|
||||
```go
|
||||
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Change the StartManualCommitment signature and set the field**
|
||||
|
||||
In `internal/session/session.go`, change the signature and store the level. Replace the header line:
|
||||
|
||||
```go
|
||||
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
|
||||
```
|
||||
|
||||
and, immediately after the existing `c.allowedClasses = append([]string(nil), allowedClasses...)` line, add:
|
||||
|
||||
```go
|
||||
c.enforcementLevel = level
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Persist and restore the level**
|
||||
|
||||
In `persistLocked`, after `snap.AllowedWindowClasses = c.allowedClasses`, add:
|
||||
|
||||
```go
|
||||
snap.EnforcementLevel = c.enforcementLevel
|
||||
```
|
||||
|
||||
In `New`, inside the `if c.runtimeState == domain.RuntimeActive && s.SessionID != ""` block, right after `c.allowedClasses = s.AllowedWindowClasses`, add:
|
||||
|
||||
```go
|
||||
c.enforcementLevel = s.EnforcementLevel
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add the test accessor**
|
||||
|
||||
In `internal/session/session.go`, near the other `*ForTest` helpers (e.g. `AllowedClassesForTest`), add:
|
||||
|
||||
```go
|
||||
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
|
||||
// only.
|
||||
func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.enforcementLevel
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update the web handler to supply a level**
|
||||
|
||||
In `internal/web/web.go`, add the field to `commitmentRequest`:
|
||||
|
||||
```go
|
||||
Enforce bool `json:"enforce"`
|
||||
```
|
||||
|
||||
and in `handleCommitment`, replace the `StartManualCommitment` call with a mapped level (Tier A honors only `block`/`warn`):
|
||||
|
||||
```go
|
||||
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)
|
||||
```
|
||||
|
||||
Add `"antidrift/internal/domain"` to `web.go`'s imports.
|
||||
|
||||
- [ ] **Step 9: Update the session test call sites**
|
||||
|
||||
In `internal/session/session_test.go`, every existing `StartManualCommitment(...)` call now needs a trailing level argument. Add `, domain.EnforcementWarn` to each (preserving today's advisory behavior). Also update the `startActive` helper to delegate to a new level-aware helper so Task 3 can start at `block`:
|
||||
|
||||
```go
|
||||
func startActive(t *testing.T, c *Controller, allowed []string) {
|
||||
t.Helper()
|
||||
startActiveLevel(t, c, allowed, domain.EnforcementWarn)
|
||||
}
|
||||
|
||||
func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) {
|
||||
t.Helper()
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("planning: %v", err)
|
||||
}
|
||||
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed, level); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The direct call sites to update (add `, domain.EnforcementWarn` before the closing paren) are at roughly lines 40, 70, 85, 107, 150, 185, 202, 231, 377, 392, 562, 891, 1069, 1161. (Use `grep -n 'StartManualCommitment(' internal/session/session_test.go` to find any the line numbers have since shifted; the old `startActive` body at ~441 is replaced by the helper above.)
|
||||
|
||||
- [ ] **Step 10: Run the full session + web suite**
|
||||
|
||||
Run: `go build ./... && go test ./internal/session/ ./internal/web/ ./internal/store/`
|
||||
Expected: PASS, including `TestEnforcementLevelPersists`. Fix any call site the grep missed.
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/store/store.go internal/session/session.go internal/session/session_test.go internal/web/web.go
|
||||
git commit -m "Thread per-commitment enforcement level through to the snapshot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fire the guard on confirmed drift at the block level
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session.go` (`enforce` import, `guard` field, `enforceTimeout`, `SetGuard`, `enforceActionLocked`, `RecordWindow`, the judge closure, `DriftView`, drift projection)
|
||||
- Modify: `internal/session/session_test.go` (`fakeGuard`, `waitGuardCalls`, enforcement tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `internal/session/session_test.go`. First the fake guard and a poll helper (`context`, `sync/atomic`, `time` already imported):
|
||||
|
||||
```go
|
||||
type fakeGuard struct{ calls int32 }
|
||||
|
||||
func (g *fakeGuard) MinimizeActive(context.Context) error {
|
||||
atomic.AddInt32(&g.calls, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitGuardCalls(t *testing.T, g *fakeGuard, min int32) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&g.calls) >= min {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("guard minimize calls never reached %d (got %d)", min, atomic.LoadInt32(&g.calls))
|
||||
}
|
||||
```
|
||||
|
||||
Then the tests:
|
||||
|
||||
```go
|
||||
func TestDriftMinimizesAtBlockViaBothPaths(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
g := &fakeGuard{}
|
||||
c.SetGuard(g)
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||
|
||||
// Async path: first off-task observation launches the judge; on confirmation
|
||||
// the guard minimizes once.
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
st := waitDriftStatus(t, c, "drifting")
|
||||
if !st.Drift.Enforced {
|
||||
t.Fatalf("Enforced should be true while drifting at block: %+v", st.Drift)
|
||||
}
|
||||
waitGuardCalls(t, g, 1)
|
||||
|
||||
// Synchronous cached path: same class again hits the per-class cache, sets
|
||||
// drifting under the lock, and minimizes again without a new judgment.
|
||||
c.RecordWindow(obs("firefox", "Reddit"))
|
||||
waitGuardCalls(t, g, 2)
|
||||
if got := atomic.LoadInt32(&g.calls); got < 2 {
|
||||
t.Fatalf("cached-drift path did not minimize: %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnLevelNeverMinimizes(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
g := &fakeGuard{}
|
||||
c.SetGuard(g)
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementWarn)
|
||||
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
st := waitDriftStatus(t, c, "drifting")
|
||||
if st.Drift.Enforced {
|
||||
t.Fatalf("Enforced must be false at warn: %+v", st.Drift)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // allow any stray enforcement goroutine to run
|
||||
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||
t.Fatalf("warn level must not minimize, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnTaskNeverMinimizes(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}})
|
||||
g := &fakeGuard{}
|
||||
c.SetGuard(g)
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||
|
||||
c.RecordWindow(obs("code", "main.go")) // local on-task match
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||
t.Fatalf("on-task must not minimize, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockWithoutGuardDoesNotPanic(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
// No guard wired.
|
||||
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
waitDriftStatus(t, c, "drifting") // must reach drifting without panicking
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Minimize|BlockWithout|WarnLevel|OnTaskNever'`
|
||||
Expected: FAIL — `SetGuard` undefined and `DriftView` has no `Enforced` field.
|
||||
|
||||
- [ ] **Step 3: Import the enforce package and add the guard field**
|
||||
|
||||
In `internal/session/session.go`, add to the import block:
|
||||
|
||||
```go
|
||||
"antidrift/internal/enforce"
|
||||
```
|
||||
|
||||
and add to the `Controller` struct (next to `judge ai.DriftJudge`):
|
||||
|
||||
```go
|
||||
guard enforce.Guard
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the timeout constant and SetGuard**
|
||||
|
||||
Add `enforceTimeout` beside `driftTimeout` in the `const` block:
|
||||
|
||||
```go
|
||||
enforceTimeout = 5 * time.Second
|
||||
```
|
||||
|
||||
Add the setter near `SetDriftJudge`:
|
||||
|
||||
```go
|
||||
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
|
||||
// enforcement; everything else behaves identically.
|
||||
func (c *Controller) SetGuard(g enforce.Guard) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.guard = g
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the enforcement-action helper**
|
||||
|
||||
In `internal/session/session.go`, add near `evaluateDriftLocked`:
|
||||
|
||||
```go
|
||||
// enforceActionLocked returns the minimize thunk when this observation should be
|
||||
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
|
||||
// returned func performs blocking X11 I/O and MUST run after the caller releases
|
||||
// c.mu, following the off-lock async-I/O discipline used by the other roles.
|
||||
func (c *Controller) enforceActionLocked() func() {
|
||||
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||
return nil
|
||||
}
|
||||
guard := c.guard
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||
defer cancel()
|
||||
if err := guard.MinimizeActive(ctx); err != nil {
|
||||
log.Printf("session: enforce minimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Wire the synchronous (cached) path in RecordWindow**
|
||||
|
||||
In `RecordWindow`, replace the tail that runs the judge launch:
|
||||
|
||||
```go
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
go launch()
|
||||
}
|
||||
c.notify()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
enforceAct := c.enforceActionLocked()
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
go launch()
|
||||
}
|
||||
if enforceAct != nil {
|
||||
go enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
```
|
||||
|
||||
(Name the local `enforceAct`, not `enforce` — `enforce` is the imported package.)
|
||||
|
||||
- [ ] **Step 7: Wire the async path in the judge closure**
|
||||
|
||||
In `evaluateDriftLocked`, the judge closure currently ends:
|
||||
|
||||
```go
|
||||
c.judgedClasses[class] = v
|
||||
if c.stats != nil && c.stats.Current.Class == class {
|
||||
c.applyVerdictLocked(v)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
```
|
||||
|
||||
Replace it with (capture the enforcement action under the lock, run it after unlock):
|
||||
|
||||
```go
|
||||
c.judgedClasses[class] = v
|
||||
var enforceAct func()
|
||||
if c.stats != nil && c.stats.Current.Class == class {
|
||||
c.applyVerdictLocked(v)
|
||||
enforceAct = c.enforceActionLocked()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if enforceAct != nil {
|
||||
enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Add the Enforced projection field**
|
||||
|
||||
In `internal/session/session.go`, add to `DriftView`:
|
||||
|
||||
```go
|
||||
Enforced bool `json:"enforced,omitempty"`
|
||||
```
|
||||
|
||||
and in `stateLocked`, where the `RuntimeActive` drift projection is built, replace:
|
||||
|
||||
```go
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/session/ -run 'Minimize|BlockWithout|WarnLevel|OnTaskNever'`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 10: Run the full session suite under the race detector**
|
||||
|
||||
Run: `go test -race ./internal/session/`
|
||||
Expected: PASS, no race warnings (the enforce goroutine reads only its captured `guard`; all controller reads are under the lock).
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session.go internal/session/session_test.go
|
||||
git commit -m "Minimize the off-task window on confirmed drift at the block level"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Daemon wiring and the on-the-wire web test
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/antidriftd/main.go` (construct + inject the guard)
|
||||
- Modify: `internal/web/web_test.go` (`stubJudge` + enforced-on-the-wire test)
|
||||
|
||||
- [ ] **Step 1: Write the failing web test**
|
||||
|
||||
Add to `internal/web/web_test.go`. A stub drift judge (the `ai`, `context`, `evidence`, `strings`, `time` imports are already present; add `time` if the build complains):
|
||||
|
||||
```go
|
||||
type stubJudge struct{ verdict ai.Verdict }
|
||||
|
||||
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||
return j.verdict, nil
|
||||
}
|
||||
|
||||
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
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}})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(s.stateJSON(), `"enforced":true`) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("state never reported enforced:true; last: %s", s.stateJSON())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it passes already at the session layer**
|
||||
|
||||
Run: `go test ./internal/web/ -run TestEnforceTogglePutsEnforcedOnTheWire`
|
||||
Expected: PASS — the level plumbing (Task 2) and projection (Task 3) already make this green. (This test guards the web boundary and the JSON contract; if it fails, the `enforce` request field or the `enforced` projection is wired wrong.)
|
||||
|
||||
- [ ] **Step 3: Wire the guard into the daemon**
|
||||
|
||||
In `cmd/antidriftd/main.go`, add the import `"antidrift/internal/enforce"`, and after the evidence source is constructed (`src := evidence.NewSource()`), inject the guard:
|
||||
|
||||
```go
|
||||
ctrl.SetGuard(enforce.NewGuard())
|
||||
log.Printf("enforce: window-minimize guard")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and vet**
|
||||
|
||||
Run: `go build ./... && go vet ./...`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/antidriftd/main.go internal/web/web_test.go
|
||||
git commit -m "Wire the enforce guard into the daemon and assert it on the wire"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Planning toggle, drift-band note, and README
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/web/static/app.js` (planning form toggle + POST field; drift-band note)
|
||||
- Modify: `internal/web/static/app.css` (note + toggle styling)
|
||||
- Modify: `README.md` (M8 paragraph)
|
||||
|
||||
This task is presentation only; it has no Go test (consistent with prior UI milestones). The behavior is already covered by Task 4's on-the-wire test; verify visually in the browser.
|
||||
|
||||
- [ ] **Step 1: Add the planning toggle to the form**
|
||||
|
||||
In `internal/web/static/app.js`, in the `} else if (rs === 'planning') {` branch, inside the final `<div class="band">` (the one with Next action / Success condition / Minutes / Allowed apps), add the toggle right before the `Start commitment` button:
|
||||
|
||||
```html
|
||||
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
|
||||
<p class="hint">Minimize off-task windows when you drift.</p>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Send the toggle value on commit**
|
||||
|
||||
In the same branch, update the `start.onclick` POST body to include `enforce`:
|
||||
|
||||
```js
|
||||
start.onclick = () => post('/commitment', {
|
||||
next_action: na.value.trim(),
|
||||
success_condition: sc.value.trim(),
|
||||
timebox_secs: Math.round(+mins.value * 60),
|
||||
allowed_window_classes: (document.getElementById('apps').value || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean),
|
||||
enforce: document.getElementById('enforce').checked,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the drift-band note**
|
||||
|
||||
In `updateActiveDrift`, in the `if (drift.status === 'drifting')` branch, add the note after the `drift-reason` line so it reads:
|
||||
|
||||
```js
|
||||
el.innerHTML = `<div><span class="pill">Drift</span></div>
|
||||
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
|
||||
${drift.enforced ? '<div class="enforce-note">Off-task window minimized.</div>' : ''}
|
||||
<div class="band-actions">
|
||||
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
|
||||
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
||||
</div>`;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add styling**
|
||||
|
||||
In `internal/web/static/app.css`, add:
|
||||
|
||||
```css
|
||||
.enforce-note { opacity: 0.8; font-size: 0.85em; }
|
||||
.enforce-toggle { display: flex; align-items: center; gap: 0.4em; }
|
||||
.hint { opacity: 0.7; font-size: 0.85em; margin: 0.2em 0 0.6em; }
|
||||
```
|
||||
|
||||
(If a `.hint` rule already exists, keep the existing one and drop the duplicate here.)
|
||||
|
||||
- [ ] **Step 5: Add the README paragraph**
|
||||
|
||||
In `README.md`, at the top of the `## Status` section, add an M8 paragraph:
|
||||
|
||||
```markdown
|
||||
**M8 (Tier A) — Enforcement (window-minimize).** Drift finally costs something.
|
||||
A planning-screen "Enforce focus" toggle arms the new `enforce.Guard` port: when
|
||||
the drift judge confirms the active window is off-task, the daemon minimizes that
|
||||
window (native X11, no `xdotool`). It is unprivileged, per-session (the chosen
|
||||
enforcement level rides the snapshot), and degrades to today's advisory behavior
|
||||
when off, unwired, or on a platform without the X11 adapter.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the build embeds the assets**
|
||||
|
||||
Run: `go build ./... && go test ./internal/web/`
|
||||
Expected: PASS (the `go:embed` static assets still build; existing web tests stay green).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/web/static/app.js internal/web/static/app.css README.md
|
||||
git commit -m "Add the enforce toggle and drift-band minimize note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (after all tasks)
|
||||
|
||||
- [ ] Run: `go build ./... && go vet ./... && go test -race ./...`
|
||||
- [ ] Expected: all packages PASS, no race warnings.
|
||||
- [ ] Manual (human): start the daemon, plan a session with **Enforce focus** checked and an allowed app, switch to an unrelated window, confirm it minimizes after the drift judge fires and the band shows "Off-task window minimized."
|
||||
@@ -1,680 +0,0 @@
|
||||
# M9 — Tame `session.go` Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Split the 1278-line `internal/session/session.go` into five focused files and consolidate the 4×-duplicated async-fetch boilerplate into one helper, with **zero behavior change**.
|
||||
|
||||
**Architecture:** Everything stays in `package session` (one `Controller` behind one `sync.Mutex` — sub-packages would force private state to be exported). Phase 1 moves declarations into focused files with no logic edits. Phase 2 adds a `runFetchAsync(timeout, fetch, stale, apply)` helper and migrates the four async roles to it one at a time. The existing `session_test.go` + `web_test.go` suites are the safety net; the contract is **green-to-green under `-race`** after every commit.
|
||||
|
||||
**Tech Stack:** Go 1.26, stdlib `testing`, `go test -race`. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Critical rules for every task
|
||||
|
||||
- **No behavior change.** Move and re-shape code; never alter what it does. No exported symbol is renamed, removed, or has its signature changed.
|
||||
- **Locate declarations by name** (`grep -n`), not by the line numbers in this plan — line numbers shift as earlier tasks move code. The line numbers here are hints from the pre-refactor file.
|
||||
- **Move bodies verbatim.** In Phase 1, cut each declaration exactly as written and paste it into the new file. Do not edit logic.
|
||||
- **Resolve imports with the compiler.** After moving declarations, run `go build ./internal/session/`. Add the imports the new file needs; delete imports the compiler reports as now-unused in `session.go`. If `goimports` is available (`go run golang.org/x/tools/cmd/goimports@latest -w internal/session/`), it does both automatically. The "expected imports" lists below are guidance, not gospel.
|
||||
- **Green gate after every commit:** `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`. All must pass. Never commit red.
|
||||
- All commands run from the repo root `/home/felixm/dev/antidrift`. Commit messages end with the `Co-Authored-By` trailer used in this repo.
|
||||
|
||||
---
|
||||
|
||||
## File Structure (target)
|
||||
|
||||
All `package session`:
|
||||
|
||||
- `session.go` — `Controller` struct, `New`, `SetClock`/`SetOnChange`/`notify`, `State`/`Deadline`, `persistLocked`, lifecycle transitions (`EnterPlanning`, `StartManualCommitment`, `Complete`/`Expire`/`enterReview`, `End`, `buildSummaryLocked`), `ErrNotPlanning`/`ErrNotActive`, the `unavailableTitle`/`sessionRetention` consts, `AllowedClassesForTest`/`EnforcementLevelForTest`.
|
||||
- `views.go` — the 11 `*View` types, the `State` type, `stateLocked`, `bucketViews`.
|
||||
- `roles.go` — `runFetchAsync` + coach/tasks/knowledge/reflection (`Set*`, `start*FetchLocked`/`RequestCoach`, `composedGroundingLocked`, `coachErrorMessage`, `buildReflectionFinishedLocked`, `buildReflectionHistory`) + their timeout/status consts.
|
||||
- `drift.go` — `RecordWindow`, `evaluateDriftLocked`, `maybeNudgeLocked`, `enforceActionLocked`, `applyVerdictLocked`, `resetDriftLocked`, `OnTask`, `Refocus`, `recordTitleLocked`, `commitmentLineLocked`, `Set{DriftJudge,Guard,Nudge}`, `recentTitlesForTest`, the drift/nudge/enforce consts (`driftDebounce`/`driftTimeout`/`enforceTimeout`/`nudgeDebounce`/`nudgeTimeout`, the `drift*` status consts, `recentTitlesMax`).
|
||||
- `stats.go` — `EvidenceStats`, `bucketKey`, `applyEvent`, `replayStats`, `keyFor`, `focusEvent`, `snapFromEvent`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Close the coverage gap — knowledge stale-discard test
|
||||
|
||||
The audit found one gap: knowledge has no dedicated stale-discard test, while coach/tasks/reflection do. Add one **before** refactoring so the shared helper's stale path is covered for every role. This is a **characterization test** — it documents existing behavior and must pass against the current (un-refactored) code.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/session_test.go`
|
||||
|
||||
- [ ] **Step 1: Add a gate channel to `fakeSource`**
|
||||
|
||||
The knowledge test double `fakeSource` (defined around line 1027 of `internal/session/session_test.go`) currently has no gate, so a load can't be held in flight. Add one, mirroring `fakeProvider` (line ~885) and `fakeCoach` (line ~251). Replace:
|
||||
|
||||
```go
|
||||
type fakeSource struct {
|
||||
profile knowledge.Profile
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||
return f.profile, f.err
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
type fakeSource struct {
|
||||
profile knowledge.Profile
|
||||
err error
|
||||
gate chan struct{} // if non-nil, Load blocks until it receives
|
||||
}
|
||||
|
||||
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.profile, f.err
|
||||
}
|
||||
```
|
||||
|
||||
This is behavior-preserving for the existing knowledge tests (they construct `fakeSource` without a `gate`, so it stays nil and `Load` never blocks).
|
||||
|
||||
- [ ] **Step 2: Write the characterization test**
|
||||
|
||||
Add to `internal/session/session_test.go`, mirroring `TestStaleTasksFetchDiscardedAfterLeavingPlanning` (line ~964). The `State.Knowledge` field is a `*KnowledgeView` accessed as `st.Knowledge.Path`/`st.Knowledge.Chars` in the existing knowledge tests:
|
||||
|
||||
```go
|
||||
// TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning exercises the
|
||||
// "left planning" arm of the discard guard in startKnowledgeFetchLocked:
|
||||
// a slow knowledge load that returns after the user has left planning must
|
||||
// not clobber fresh state.
|
||||
func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
staleGate := make(chan struct{})
|
||||
stale := &fakeSource{
|
||||
profile: knowledge.Profile{Text: "STALE", Path: "/stale"},
|
||||
gate: staleGate,
|
||||
}
|
||||
c.SetKnowledge(stale)
|
||||
|
||||
if err := c.EnterPlanning(); err != nil { // launches the gated load (gen 1)
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Leave planning while the gen-1 load is still blocked on the gate.
|
||||
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
close(staleGate) // release gen 1; it must be discarded (left planning)
|
||||
// Give the released goroutine time to attempt its commit.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
st := c.State()
|
||||
if st.Knowledge != nil && st.Knowledge.Chars != 0 {
|
||||
t.Fatalf("stale knowledge fetch clobbered state: %+v", st.Knowledge)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it — expect PASS (characterizes current behavior)**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning -v`
|
||||
Expected: PASS. (It documents existing behavior; if it fails, the test is wrong or the fake's gate is mis-wired — fix the test, not production code.)
|
||||
|
||||
- [ ] **Step 4: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/session_test.go
|
||||
git commit -m "Add knowledge stale-discard characterization test
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extract `views.go` (file split, no logic change)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/views.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file with the package clause**
|
||||
|
||||
Create `internal/session/views.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the declarations verbatim**
|
||||
|
||||
Cut these declarations from `session.go` (locate each by name with `grep -n 'type CommitmentView' internal/session/session.go` etc.) and paste them, unedited, into `views.go`:
|
||||
|
||||
- the view types, in this order: `CommitmentView`, `ProposalView`, `DriftView`, `CoachView`, `TaskView`, `TasksView`, `KnowledgeView`, `ReflectionView`, `WindowView`, `BucketView`, `EvidenceView`, `State`
|
||||
- the method `func (c *Controller) stateLocked() State { ... }`
|
||||
- the function `func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { ... }`
|
||||
|
||||
Do not change a single line of their bodies.
|
||||
|
||||
- [ ] **Step 3: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`views.go` is expected to need: `sort`, `time`, `antidrift/internal/domain`, `antidrift/internal/evidence`, `antidrift/internal/tasks`. Add whatever the compiler reports as undefined, and remove from `session.go` any import the compiler now reports as unused. (Or run `goimports -w internal/session/` to do both.)
|
||||
|
||||
- [ ] **Step 4: Green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS (pure code motion — behavior is identical).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/views.go internal/session/session.go
|
||||
git commit -m "Split session views into views.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `stats.go` (file split, no logic change)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/stats.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file**
|
||||
|
||||
Create `internal/session/stats.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the declarations verbatim**
|
||||
|
||||
Cut these from `session.go` and paste unedited into `stats.go`:
|
||||
|
||||
- `type bucketKey struct{ Class, Title string }`
|
||||
- `type EvidenceStats struct { ... }`
|
||||
- `func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot)`
|
||||
- `func (c *Controller) replayStats(sessionID string)`
|
||||
- `func keyFor(snap evidence.WindowSnapshot) bucketKey`
|
||||
- `func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent`
|
||||
- `func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot`
|
||||
|
||||
- [ ] **Step 3: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`stats.go` is expected to need: `time`, `antidrift/internal/evidence`, `antidrift/internal/store`. Add/remove per the compiler (or `goimports -w`).
|
||||
|
||||
- [ ] **Step 4: Green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/stats.go internal/session/session.go
|
||||
git commit -m "Split evidence-stats accounting into stats.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extract `drift.go` (file split, no logic change)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/drift.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file**
|
||||
|
||||
Create `internal/session/drift.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the consts verbatim**
|
||||
|
||||
Cut these const groups from `session.go` and paste into `drift.go`:
|
||||
|
||||
- the block `const ( driftDebounce ... driftTimeout ... enforceTimeout ... nudgeDebounce ... nudgeTimeout ... )`
|
||||
- `const recentTitlesMax = 10`
|
||||
- the block `const ( driftIdle ... driftPending ... driftOnTask ... driftDrifting ... )`
|
||||
|
||||
- [ ] **Step 3: Move the methods verbatim**
|
||||
|
||||
Cut these from `session.go` and paste unedited into `drift.go`:
|
||||
|
||||
- `Set{DriftJudge,Guard,Nudge}` — i.e. `SetDriftJudge`, `SetGuard`, `SetNudge`
|
||||
- `resetDriftLocked`, `OnTask`, `Refocus`
|
||||
- `commitmentLineLocked`
|
||||
- `RecordWindow`, `enforceActionLocked`, `evaluateDriftLocked`, `maybeNudgeLocked`
|
||||
- `recordTitleLocked`, `applyVerdictLocked`
|
||||
- `recentTitlesForTest`
|
||||
|
||||
- [ ] **Step 4: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`drift.go` is expected to need: `context`, `log`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/enforce`, `antidrift/internal/evidence`. Add/remove per the compiler (or `goimports -w`). Pay attention: `session.go` may still need `enforce`/`ai` for fields in the `Controller` struct (the struct itself stays in `session.go`), so do not blindly delete its imports — let the compiler decide.
|
||||
|
||||
- [ ] **Step 5: Green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/drift.go internal/session/session.go
|
||||
git commit -m "Split drift/nudge/enforcement into drift.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Extract `roles.go` (file split, no logic change)
|
||||
|
||||
This isolates the four async roles so Phase 2's consolidation is a clean diff. `session.go` becomes the remainder (core controller + lifecycle).
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/session/roles.go`
|
||||
- Modify: `internal/session/session.go`
|
||||
|
||||
- [ ] **Step 1: Create the new file**
|
||||
|
||||
Create `internal/session/roles.go`:
|
||||
|
||||
```go
|
||||
package session
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the consts verbatim**
|
||||
|
||||
Cut these from `session.go` into `roles.go`:
|
||||
|
||||
- `const coachTimeout = 60 * time.Second` and the `const ( coachIdle ... coachPending ... coachReady ... coachError )` block
|
||||
- `const tasksTimeout = 30 * time.Second` and the `const ( tasksIdle ... tasksReady ... tasksError )` block
|
||||
- `const knowledgeTimeout = 10 * time.Second` and the `const ( knowledgeIdle ... knowledgeError )` block
|
||||
- `const reflectionTimeout = 30 * time.Second`, `const reflectionHistoryN = 5`, `const reflectionTopBuckets = 3`, and the `const ( reflectionIdle ... reflectionAbsent )` block
|
||||
|
||||
- [ ] **Step 3: Move the methods/functions verbatim**
|
||||
|
||||
Cut these from `session.go` into `roles.go`:
|
||||
|
||||
- coach: `SetCoach`, `resetCoachLocked`, `composedGroundingLocked`, `RequestCoach`, `coachErrorMessage`
|
||||
- tasks: `SetTasks`, `startTasksFetchLocked`
|
||||
- knowledge: `SetKnowledge`, `SetKnowledgePath`, `startKnowledgeFetchLocked`
|
||||
- reflection: `SetReviewer`, `startReflectionFetchLocked`, `buildReflectionFinishedLocked`, `buildReflectionHistory`
|
||||
|
||||
- [ ] **Step 4: Fix imports**
|
||||
|
||||
Run: `go build ./internal/session/`
|
||||
`roles.go` is expected to need: `context`, `fmt`, `strings`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/knowledge`, `antidrift/internal/store`, `antidrift/internal/tasks`. Add/remove per the compiler (or `goimports -w`).
|
||||
|
||||
- [ ] **Step 5: Green gate + confirm the monolith shrank**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
Run: `wc -l internal/session/*.go`
|
||||
Expected: `session.go` is now well under ~450 lines; `views.go`/`roles.go`/`drift.go`/`stats.go` carry the rest.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go internal/session/session.go
|
||||
git commit -m "Split async AI roles into roles.go
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add `runFetchAsync` and migrate the tasks role
|
||||
|
||||
Phase 2 begins. Introduce the helper and convert the first role. The helper owns only the goroutine dance; the role keeps its setup, stale guard, and apply branches.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Add the helper**
|
||||
|
||||
In `internal/session/roles.go`, add:
|
||||
|
||||
```go
|
||||
// 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.
|
||||
// fetch performs the I/O with no lock held; stale reports whether to discard the
|
||||
// result; apply records it under the re-acquired lock (and persists itself when
|
||||
// the role requires it). On a non-stale completion the controller notifies.
|
||||
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()
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Migrate `startTasksFetchLocked`**
|
||||
|
||||
Replace the body of `startTasksFetchLocked` (the `go func() { ... }()` block) so the function reads exactly:
|
||||
|
||||
```go
|
||||
func (c *Controller) startTasksFetchLocked() {
|
||||
c.tasksList = nil
|
||||
if c.tasksProvider == nil {
|
||||
c.tasksStatus = tasksIdle
|
||||
return
|
||||
}
|
||||
c.tasksGen++
|
||||
gen := c.tasksGen
|
||||
c.tasksStatus = tasksPending
|
||||
provider := c.tasksProvider
|
||||
var list []tasks.Task
|
||||
var err error
|
||||
c.runFetchAsync(tasksTimeout,
|
||||
func(ctx context.Context) { list, err = provider.Today(ctx) },
|
||||
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
|
||||
func() {
|
||||
if err != nil {
|
||||
c.tasksStatus = tasksError
|
||||
c.tasksList = nil
|
||||
} else {
|
||||
c.tasksStatus = tasksReady
|
||||
c.tasksList = list
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Tasks' -v`
|
||||
Expected: PASS — including `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`, `TestStaleTasksFetchDiscardedAfterLeavingPlanning`.
|
||||
|
||||
- [ ] **Step 4: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Add runFetchAsync helper and migrate the tasks fetch
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Migrate the knowledge role
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Migrate `startKnowledgeFetchLocked`**
|
||||
|
||||
Replace the `go func() { ... }()` block so the function reads exactly (note the three-branch apply and the `knowledgePath` write-back are preserved verbatim, now inside the `apply` closure):
|
||||
|
||||
```go
|
||||
func (c *Controller) startKnowledgeFetchLocked() {
|
||||
c.knowledgeText = ""
|
||||
c.knowledgeChars = 0
|
||||
if c.knowledgeSrc == nil {
|
||||
c.knowledgeStatus = knowledgeIdle
|
||||
return
|
||||
}
|
||||
c.knowledgeGen++
|
||||
gen := c.knowledgeGen
|
||||
c.knowledgeStatus = knowledgePending
|
||||
src := c.knowledgeSrc
|
||||
path := c.knowledgePath
|
||||
var prof knowledge.Profile
|
||||
var err error
|
||||
c.runFetchAsync(knowledgeTimeout,
|
||||
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
|
||||
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
|
||||
func() {
|
||||
if err != nil {
|
||||
c.knowledgeStatus = knowledgeError
|
||||
c.knowledgeText = ""
|
||||
c.knowledgeChars = 0
|
||||
if prof.Path != "" {
|
||||
c.knowledgePath = prof.Path
|
||||
}
|
||||
} else if prof.Text == "" {
|
||||
c.knowledgeStatus = knowledgeAbsent
|
||||
c.knowledgeText = ""
|
||||
c.knowledgeChars = 0
|
||||
c.knowledgePath = prof.Path
|
||||
} else {
|
||||
c.knowledgeStatus = knowledgeReady
|
||||
c.knowledgeText = prof.Text
|
||||
c.knowledgeChars = len(prof.Text)
|
||||
c.knowledgePath = prof.Path
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Knowledge' -v`
|
||||
Expected: PASS — including `TestEnterPlanningLoadsKnowledge`, `TestKnowledgeAbsentWhenEmptyText`, `TestKnowledgeLoadError`, `TestNoSourceNoKnowledgeView`, `TestKnowledgeViewAbsentOutsidePlanning`, `TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning` (from Task 1).
|
||||
|
||||
- [ ] **Step 3: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Migrate the knowledge fetch onto runFetchAsync
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Migrate the coach role
|
||||
|
||||
`RequestCoach` is the request-triggered entry point: it manages its own lock and notifies the pending state **before** launching. Keep that pre-launch unlock+notify; only the trailing `go func() { ... }()` is replaced.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Migrate `RequestCoach`**
|
||||
|
||||
Replace the trailing goroutine. The function must read exactly:
|
||||
|
||||
```go
|
||||
func (c *Controller) RequestCoach(intent string) error {
|
||||
c.mu.Lock()
|
||||
if c.runtimeState != domain.RuntimePlanning {
|
||||
c.mu.Unlock()
|
||||
return ErrNotPlanning
|
||||
}
|
||||
if c.coach == nil {
|
||||
c.coachStatus = coachError
|
||||
c.coachErr = "coach unavailable"
|
||||
c.coachProposal = nil
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
return nil
|
||||
}
|
||||
c.coachGen++
|
||||
gen := c.coachGen
|
||||
c.coachStatus = coachPending
|
||||
c.coachErr = ""
|
||||
c.coachProposal = nil
|
||||
coach := c.coach
|
||||
grounding := c.composedGroundingLocked()
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
|
||||
var prop ai.Proposal
|
||||
var err error
|
||||
c.runFetchAsync(coachTimeout,
|
||||
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
|
||||
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
|
||||
func() {
|
||||
if err != nil {
|
||||
c.coachStatus = coachError
|
||||
c.coachErr = coachErrorMessage(err)
|
||||
c.coachProposal = nil
|
||||
} else {
|
||||
c.coachStatus = coachReady
|
||||
c.coachProposal = &prop
|
||||
c.coachErr = ""
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Note: `runFetchAsync` is called here **after** `c.mu.Unlock()`. That is correct — the helper only spawns the goroutine and touches no `c` field before the goroutine re-locks, so holding the lock is not required.
|
||||
|
||||
- [ ] **Step 2: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Coach' -v`
|
||||
Expected: PASS — including `TestRequestCoachReady`, `TestRequestCoachError`, `TestRequestCoachUnavailable`, `TestRequestCoachWrongState`, `TestRequestCoachStaleResultDiscarded`, `TestCoachReceivesCachedGrounding`, `TestLeavingPlanningClearsCoach`, `TestCarryForwardGroundsNextCoach`.
|
||||
|
||||
- [ ] **Step 3: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Migrate the coach fetch onto runFetchAsync
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Migrate the reflection role
|
||||
|
||||
Reflection is the subtle one: its stale guard is **gen-only** (no Planning gate — the carry-forward must survive `End` before the reviewer returns), it reads `history` **synchronously under the lock before** the goroutine (a happens-before requirement against `End`'s audit-chain append), and its apply calls `persistLocked`. All three must be preserved.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/session/roles.go`
|
||||
|
||||
- [ ] **Step 1: Migrate `startReflectionFetchLocked`**
|
||||
|
||||
Replace the trailing `go func() { ... }()` block. The function must read exactly (the synchronous `history := buildReflectionHistory(c.auditPath)` stays **outside** the closures, before the `runFetchAsync` call; `persistLocked` moves **into** the `apply` closure):
|
||||
|
||||
```go
|
||||
func (c *Controller) startReflectionFetchLocked() {
|
||||
c.reflectionRecap = ""
|
||||
c.carryForward = ""
|
||||
if c.reviewer == nil {
|
||||
c.reflectionStatus = reflectionIdle
|
||||
return
|
||||
}
|
||||
c.reflectionGen++
|
||||
gen := c.reflectionGen
|
||||
c.reflectionStatus = reflectionPending
|
||||
reviewer := c.reviewer
|
||||
finished := c.buildReflectionFinishedLocked()
|
||||
// Read the history synchronously, here under the lock, on purpose: it must
|
||||
// happen-before End appends the just-finished session to the audit chain, so
|
||||
// that session is excluded from "recent history" and not double-counted (it
|
||||
// is already carried in `finished`). Moving this into the goroutine would
|
||||
// race with End's append and reintroduce that double-count. The read is
|
||||
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
|
||||
// on any hot path.
|
||||
history := buildReflectionHistory(c.auditPath)
|
||||
var refl ai.Reflection
|
||||
var err error
|
||||
c.runFetchAsync(reflectionTimeout,
|
||||
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
|
||||
func() bool { return gen != c.reflectionGen },
|
||||
func() {
|
||||
if err != nil || strings.TrimSpace(refl.Recap) == "" {
|
||||
c.reflectionStatus = reflectionAbsent
|
||||
c.reflectionRecap = ""
|
||||
c.carryForward = ""
|
||||
} else {
|
||||
c.reflectionStatus = reflectionReady
|
||||
c.reflectionRecap = refl.Recap
|
||||
c.carryForward = refl.CarryForward
|
||||
}
|
||||
_ = c.persistLocked()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Confirm the reviewer's return type is `ai.Reflection` (grep `internal/ai` for `func (b *Backend) Review`); if the type name differs, match it in the `var refl` declaration.
|
||||
|
||||
- [ ] **Step 2: Targeted tests**
|
||||
|
||||
Run: `go test -race ./internal/session/ -run 'Reflection|CarryForward' -v`
|
||||
Expected: PASS — including `TestReflectionFetchedOnReview`, `TestNoReviewerYieldsIdleReflection`, `TestCarryForwardGroundsNextCoach`, `TestReflectionStaleResultDiscarded`, `TestCarryForwardSurvivesRestart`.
|
||||
|
||||
- [ ] **Step 3: Full green gate**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/session/roles.go
|
||||
git commit -m "Migrate the reflection fetch onto runFetchAsync
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Final verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Confirm no inline async-fetch goroutines remain**
|
||||
|
||||
Run: `grep -n 'go func()' internal/session/*.go`
|
||||
Expected: the only matches are inside `runFetchAsync` (in `roles.go`) and any pre-existing goroutines in `drift.go`/elsewhere that are **not** the four migrated role fetches. The coach/tasks/knowledge/reflection fetches must no longer contain their own `go func()` — they go through `runFetchAsync`.
|
||||
|
||||
- [ ] **Step 2: Confirm the split**
|
||||
|
||||
Run: `wc -l internal/session/*.go`
|
||||
Expected: five focused files (`session.go`, `views.go`, `roles.go`, `drift.go`, `stats.go`), none dominating; the old 1278-line monolith is gone.
|
||||
|
||||
- [ ] **Step 3: Full suite under the race detector**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test -race ./...`
|
||||
Expected: all packages PASS, no race warnings.
|
||||
|
||||
- [ ] **Step 4: Confirm behavior contract**
|
||||
|
||||
Confirm via `git log --oneline` that the milestone is a series of small commits, each green, with no change to any exported signature (spot-check: `git diff <first-m9-commit>^..HEAD -- internal/session/session.go internal/web internal/store cmd` shows no signature/behavior changes outside the moved/▸consolidated session code, and no `.go` file outside `internal/session/` was modified except as already committed in prior milestones).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the executor)
|
||||
|
||||
- Every task is **green-to-green**; if any `go test -race` goes red, stop and fix before committing — a red gate means a real behavior change slipped in.
|
||||
- The risky preservation points, all called out in their tasks: reflection's **gen-only** stale guard, its **synchronous history read** before the goroutine, and its **`persistLocked`** in apply; coach's **pre-launch unlock+notify**; knowledge's **three-branch apply + `knowledgePath` write-back**.
|
||||
- No exported symbol is renamed or moved out of `package session`; `web_test.go`'s cross-package use of `AllowedClassesForTest`/`EnforcementLevelForTest` keeps working because those stay in `session.go` as regular (non-`_test.go`) methods.
|
||||
@@ -1,674 +0,0 @@
|
||||
# Commitment OS Design
|
||||
|
||||
Date: 2026-05-25
|
||||
|
||||
## Purpose
|
||||
|
||||
AntiDrift should evolve from a session timer and window-rating tool into a commitment operating system: a personal agency layer that makes computer use explicit, constrained, observable, and resistant to casual tampering.
|
||||
|
||||
The system addresses distraction as unconscious context switching, not only as access to bad websites. The guiding rule is:
|
||||
|
||||
```text
|
||||
No unchosen transitions.
|
||||
```
|
||||
|
||||
The computer should not silently enter unconstrained use. It should be in one of these explicit modes:
|
||||
|
||||
- active commitment;
|
||||
- deliberate transition;
|
||||
- constrained planning;
|
||||
- review;
|
||||
- locked state;
|
||||
- delayed administrative override.
|
||||
|
||||
Tamper resistance is a friction gradient against the user's impulsive self. It is not a perfect security boundary. The goal is to defeat impulsive and casual bypasses, delay deliberate bypasses, and make drift visible.
|
||||
|
||||
## Architecture
|
||||
|
||||
The system has five layers that share a common commitment model.
|
||||
|
||||
1. Orientation: projects, responsibilities, values, tasks, next actions, energy constraints.
|
||||
2. Commitment: a concrete next action, success condition, allowed context, timebox, and transition policy.
|
||||
3. Live work mode: desktop, browser, terminal, files, ActivityWatch, window tracking, and future evidence sources.
|
||||
4. Containment: a privileged guardian that enforces the selected commitment across the live environment.
|
||||
5. Reflection: session outcomes, drift events, transition reasons, rule adjustments, and planner updates.
|
||||
|
||||
The commitment model sits between planning and enforcement. Planner integration can later create commitments from tasks. Enforcement operates on policy snapshots derived from commitments without understanding planner internals.
|
||||
|
||||
## Threat Model And Scope
|
||||
|
||||
The adversary is the same person in a less reflective state. The system should be honest about what local software can and cannot enforce.
|
||||
|
||||
| Tier | Bypass effort | Examples | Realistic goal |
|
||||
| --- | --- | --- | --- |
|
||||
| 0: impulse | less than 1 second | Alt-tab, open browser, type URL, leave desk without choosing transition | Defeat with blocking, prompts, overlays, and explicit transitions |
|
||||
| 1: casual | seconds | Kill process, stop user service, close app, quit ActivityWatch | Defeat with watchdogs, privilege separation, and restart behavior |
|
||||
| 2: determined | minutes | Use sudo, edit config, disable systemd units, reboot, manipulate clock | Delay and audit through delayed admin path and break-glass flow |
|
||||
| 3: premeditated | unconstrained | Boot live USB, GRUB recovery, second device, phone, hardware removal | Out of scope for local software |
|
||||
|
||||
In-scope bypasses:
|
||||
|
||||
- opening disallowed domains or apps;
|
||||
- quitting AntiDrift or ActivityWatch;
|
||||
- stopping user-level services;
|
||||
- entering unconstrained planning;
|
||||
- changing rules without delayed admin access;
|
||||
- crash-looping the user agent;
|
||||
- malformed policy snapshots;
|
||||
- clock manipulation attempts visible to the running system;
|
||||
- unmonitored desk absence after future presence sensing exists.
|
||||
|
||||
Out-of-scope or explicitly limited:
|
||||
|
||||
- second phones, tablets, or other computers;
|
||||
- booting from external media or recovery shell;
|
||||
- physical tampering;
|
||||
- fully premeditated bypasses with unlimited time;
|
||||
- coercive monitoring of another person.
|
||||
|
||||
Known edge cases to address or scope per implementation stage:
|
||||
|
||||
- switching TTYs or desktop sessions;
|
||||
- Wayland compositor limitations;
|
||||
- VMs and nested display servers;
|
||||
- SSH into the same machine;
|
||||
- browser profiles and extensions;
|
||||
- Flatpak/Snap application identity;
|
||||
- multi-user machines;
|
||||
- multiple monitors, virtual desktops, and tiling window managers;
|
||||
- direct modification of ActivityWatch data.
|
||||
|
||||
Stage 1 does not need to solve every edge case, but it must not pretend they are solved.
|
||||
|
||||
## Linux Target Environment
|
||||
|
||||
The first target is the user's Linux workstation.
|
||||
|
||||
Stage 1 should support the current X11-style active-window model already used by AntiDrift through `xdotool`, plus ActivityWatch evidence when available. If the session is Wayland and active window metadata cannot be collected reliably, the system must surface that as a degraded evidence mode rather than silently reporting false confidence.
|
||||
|
||||
Stage 2 should choose concrete Linux enforcement mechanisms per target environment:
|
||||
|
||||
- systemd system service for the privileged guardian;
|
||||
- systemd user service or normal user process for the agent;
|
||||
- nftables or DNS-level controls for domain blocking;
|
||||
- window-class interruption where active window details are available;
|
||||
- ActivityWatch watchdog where ActivityWatch is installed and expected.
|
||||
|
||||
Wayland support should be compositor-specific. GNOME Wayland, KDE Wayland, Sway, and Hyprland may need separate adapters. Unknown or unsupported environments should fail into a conservative mode: planning/review may work, but enforcement claims are limited.
|
||||
|
||||
## Domain Model
|
||||
|
||||
The first-pass `Commitment` concept should be split into three related records.
|
||||
|
||||
### Commitment
|
||||
|
||||
User-facing intent and planning artifact.
|
||||
|
||||
```text
|
||||
Commitment
|
||||
id
|
||||
created_at
|
||||
source: manual | planner | recurring | recovery | template
|
||||
project_id: optional initially, planner-backed later
|
||||
template_id: optional
|
||||
next_action: concrete executable action
|
||||
success_condition: what counts as done or meaningfully advanced
|
||||
timebox
|
||||
transition_policy_id
|
||||
state: draft | active | paused | completed | abandoned | violated
|
||||
```
|
||||
|
||||
Only one commitment may be active at a time. Task switches go through `Transition` and either resume the current commitment, abandon it, or create/select a new one.
|
||||
|
||||
### PolicySnapshot
|
||||
|
||||
Versioned enforcement contract consumed by the guardian.
|
||||
|
||||
```text
|
||||
PolicySnapshot
|
||||
id
|
||||
commitment_id
|
||||
schema_version
|
||||
created_at
|
||||
runtime_state: locked | planning | active | transition | review | admin_override
|
||||
enforcement_level: observe | warn | block | locked
|
||||
allowed_context
|
||||
required_monitors
|
||||
violation_actions
|
||||
expires_at
|
||||
generated_by_agent_version
|
||||
```
|
||||
|
||||
The guardian consumes `PolicySnapshot`, not planner internals.
|
||||
|
||||
### SessionRecord And EventLog
|
||||
|
||||
Append-only history of what happened.
|
||||
|
||||
```text
|
||||
SessionRecord
|
||||
id
|
||||
commitment_id
|
||||
started_at
|
||||
ended_at
|
||||
outcome: completed | partial | abandoned | violated
|
||||
review_rating
|
||||
review_notes
|
||||
|
||||
EventLog
|
||||
sequence
|
||||
timestamp
|
||||
event_type
|
||||
commitment_id
|
||||
runtime_state
|
||||
payload
|
||||
previous_hash
|
||||
hash
|
||||
```
|
||||
|
||||
The event log is the source for review, audit, and later planner writeback.
|
||||
|
||||
## Allowed Context Semantics
|
||||
|
||||
Allowed context should be explicit and conservative. Stage 1 may only observe or warn, but the matching rules should be defined early so Stage 2 can enforce the same contract.
|
||||
|
||||
```yaml
|
||||
allowed_context:
|
||||
window_classes:
|
||||
match: exact_lowercase
|
||||
values:
|
||||
- code
|
||||
- alacritty
|
||||
window_titles:
|
||||
match: substring
|
||||
values:
|
||||
- antidrift
|
||||
domains:
|
||||
match: exact_or_subdomain
|
||||
values:
|
||||
- github.com
|
||||
- docs.python.org
|
||||
repos:
|
||||
match: canonical_path_prefix
|
||||
values:
|
||||
- ~/dev/antidrift
|
||||
commands:
|
||||
match: executable_basename
|
||||
values:
|
||||
- cargo
|
||||
- git
|
||||
- rg
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Domains include subdomains unless explicitly marked exact-only.
|
||||
- Full URLs should not be stored by default; store domain and coarse path only when needed.
|
||||
- Window classes are preferred over titles because titles can contain private data and change frequently.
|
||||
- Unknown windows are handled by `enforcement_level`: record in `observe`, prompt in `warn`, interrupt in `block`, and force `Locked` on repeated or severe violations in `locked`.
|
||||
- Terminals are hard to classify. Stage 1 may treat terminal windows as allowed based on window class and record command evidence later only when a shell integration exists.
|
||||
- Child process handling is deferred until command/process enforcement is designed. Stage 2 should not claim process-level policy without cgroups, namespaces, AppArmor, seccomp, or equivalent mechanisms.
|
||||
- Commitments may use per-resource actions. Example: block social domains, warn on unfamiliar apps, observe unknown terminal commands.
|
||||
|
||||
Commitment templates and project defaults should reduce manual allowlist construction:
|
||||
|
||||
- `deep coding`;
|
||||
- `writing`;
|
||||
- `admin`;
|
||||
- `research`;
|
||||
- `rest`;
|
||||
- project-specific default repos, apps, and documentation domains.
|
||||
|
||||
Time-limited context expansion should be allowed when work legitimately discovers a new need. Expansion must be explicit, audited, and bounded, such as "allow `docs.rs` for 20 minutes for this commitment."
|
||||
|
||||
## Runtime State Machine
|
||||
|
||||
Runtime state controls what the machine is allowed to do now.
|
||||
|
||||
```text
|
||||
Locked
|
||||
No valid active commitment. Distracting/default computer use is blocked.
|
||||
|
||||
Planning
|
||||
Constrained UI for inspecting tasks/projects and creating or choosing a commitment.
|
||||
|
||||
Active
|
||||
A commitment is running. Allowed context is available; disallowed context is blocked or interrupted.
|
||||
|
||||
Transition
|
||||
Intentional state change: bodily break, reset, task switch, or session end.
|
||||
Requires reason, expected duration, and return target.
|
||||
|
||||
Review
|
||||
Session ended. The user rates relevance, marks outcome, and records drift/avoidance patterns.
|
||||
|
||||
Admin Override
|
||||
Privileged change path. Requires delayed admin access and leaves audit evidence.
|
||||
```
|
||||
|
||||
Legal transitions:
|
||||
|
||||
| From | To | Guard | Side effects |
|
||||
| --- | --- | --- | --- |
|
||||
| Locked | Planning | Planning UI requested | Apply planning policy, start planning timer |
|
||||
| Planning | Active | Valid commitment and policy snapshot accepted by guardian | Create session record, apply active policy |
|
||||
| Planning | Locked | Cancel, timeout, invalid policy, or idle | Apply locked policy |
|
||||
| Active | Transition | Reason, expected duration, and return target provided | Record transition start, apply transition policy |
|
||||
| Active | Review | Commitment completed, abandoned, or timebox expired | Stop active policy, collect evidence summary |
|
||||
| Active | Locked | Severe violation, policy failure, or monitor failure | Record violation, apply locked policy |
|
||||
| Transition | Active | Return before timeout to same commitment | Record return, reapply active policy |
|
||||
| Transition | Planning | Task switch requested | Mark current commitment paused/abandoned as chosen, apply planning policy |
|
||||
| Transition | Review | End session requested | Collect review data |
|
||||
| Transition | Locked | Timeout exceeded or transition policy failure | Record violation, apply locked policy |
|
||||
| Review | Planning | Continue working | Store review, apply planning policy |
|
||||
| Review | Locked | End work period | Store review, apply locked policy |
|
||||
| Any | Admin Override | Delayed admin path completed | Record override, apply requested privileged change |
|
||||
| Admin Override | Previous/new state | Override ends | Record result, apply selected policy |
|
||||
|
||||
Planning must never become unconstrained browsing. It has its own policy:
|
||||
|
||||
- only the planning UI, local task data, and approved references are available;
|
||||
- planning is time-limited;
|
||||
- evidence collection remains active;
|
||||
- opening arbitrary browser tabs from Planning is a violation unless explicitly allowed.
|
||||
|
||||
Suspend/hibernate and reboot should resume into `Locked` unless a valid policy can be restored and verified. A violated commitment may be resumed only through `Review` or `Planning`; resumption should create a visible event.
|
||||
|
||||
## Commitment Lifecycle
|
||||
|
||||
Commitment state is distinct from runtime state.
|
||||
|
||||
| From | To | Guard | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| draft | active | Runtime enters `Active` with accepted policy | Only one active commitment at a time |
|
||||
| active | paused | Runtime enters `Transition` with return target | Paused does not imply unconstrained use |
|
||||
| paused | active | User returns before transition timeout | Same commitment resumes |
|
||||
| active | completed | Success condition met or user completes in review | Requires review |
|
||||
| active | abandoned | User chooses task switch/end without completion | Requires reason |
|
||||
| active | violated | Severe or unresolved violation | May force runtime `Locked` |
|
||||
| paused | abandoned | Transition becomes task switch/end | Requires reason |
|
||||
| violated | active | Explicit recovery flow | Audited; not automatic |
|
||||
| violated | abandoned | Review confirms abandonment | Stored in history |
|
||||
|
||||
## Guardian IPC Contract
|
||||
|
||||
The user agent and guardian communicate through a narrow local API.
|
||||
|
||||
Recommended transport: root-owned Unix domain socket with a small JSON-lines protocol. D-Bus can be reconsidered later if desktop integration requires it, but the first design should avoid unnecessary framework surface.
|
||||
|
||||
Authentication and integrity:
|
||||
|
||||
- The socket path is owned by root and writable only by the expected user/group.
|
||||
- The guardian checks peer credentials with `SO_PEERCRED`.
|
||||
- Messages include `schema_version`, monotonic sequence, and request id.
|
||||
- The guardian rejects malformed, unknown-version, stale, or unauthenticated messages.
|
||||
- Policy changes are logged before and after application.
|
||||
|
||||
Minimal API:
|
||||
|
||||
```text
|
||||
ApplyPolicy(PolicySnapshot) -> Result
|
||||
ReportEvent(Event) -> Ack
|
||||
GetStatus() -> SystemState
|
||||
RequestOverride(OverrideRequest) -> OverrideResult
|
||||
```
|
||||
|
||||
Failure behavior:
|
||||
|
||||
- If the agent disconnects while `Active`, the guardian keeps the last valid policy for a short grace period, then moves to `Locked`.
|
||||
- If the agent sends malformed policy, the guardian rejects it and keeps the previous valid policy; repeated malformed policy escalates to `Locked`.
|
||||
- If IPC is unavailable, the agent should show degraded state and the system should not claim enforcement.
|
||||
- If guardian and agent schema versions are incompatible, the system should enter `Locked` with an actionable error and break-glass instructions.
|
||||
|
||||
## Enforcement Mechanism Matrix
|
||||
|
||||
Stage 2 enforcement should be explicit about feasibility.
|
||||
|
||||
| Resource | Stage 1 | Stage 2 candidate | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Domain | observe from ActivityWatch/browser data if available | nftables or DNS-level blocking | Prefer domains over full URLs for privacy |
|
||||
| App/window | active-window tracking, minimize or overlay | window-class interruption, minimize, SIGSTOP only where safe | Depends on X11/Wayland adapter |
|
||||
| Agent health | local process check | systemd restart and guardian watchdog | Casual process killing should fail |
|
||||
| ActivityWatch health | observe availability | guardian restart and escalation after repeated failure | Direct DB tampering only audited if detectable |
|
||||
| Config changes | local file checks | root-owned config, delayed admin required | Config writes must be audited |
|
||||
| Commands/processes | mostly out of scope | future shell integration, cgroups, AppArmor, seccomp, namespaces | Do not overclaim early |
|
||||
| Files/repos | local path metadata | future filesystem policy only if needed | Path allowlists are mostly planning/evidence initially |
|
||||
| Presence | out of scope | future opt-in sensor events | Requires separate ethics/design review |
|
||||
|
||||
Stage 1 should include Tier 0 friction even without privileged blocking:
|
||||
|
||||
- full-screen or high-priority overlay on violation where feasible;
|
||||
- dismissal requires entering a reason;
|
||||
- explicit transition prompts;
|
||||
- local event logging tied to the active commitment.
|
||||
|
||||
## Delayed Admin And Break-Glass Recovery
|
||||
|
||||
The delayed admin mechanism is load-bearing and must be concrete before Stage 2 hardening.
|
||||
|
||||
Initial assumption:
|
||||
|
||||
- daily work happens in a non-admin user account;
|
||||
- admin access exists in a separate admin account or equivalent path;
|
||||
- admin access already has a delay, currently about 10 minutes, and can be lengthened;
|
||||
- privileged enforcement changes require that delayed path.
|
||||
|
||||
Actions requiring delayed admin:
|
||||
|
||||
- disabling guardian service;
|
||||
- changing locked policy files;
|
||||
- changing blocklists or allowlists outside explicit time-limited context expansion;
|
||||
- uninstalling enforcement;
|
||||
- disabling watchdog behavior;
|
||||
- emergency break-glass.
|
||||
|
||||
Override requests should be logged at request time and completion time:
|
||||
|
||||
```text
|
||||
OverrideRequest
|
||||
id
|
||||
requested_at
|
||||
requester_user
|
||||
reason
|
||||
requested_action
|
||||
earliest_allowed_at
|
||||
completed_at
|
||||
result
|
||||
```
|
||||
|
||||
Pre-scheduled instant overrides weaken the system. The guardian should reject override tokens or sentinel files created before the current request window unless they are part of the audited delayed-admin protocol.
|
||||
|
||||
Break-glass recovery must be independent of the normal agent UI. Example: a privileged command or root-owned sentinel file disables enforcement for 10 minutes after delayed admin access. It must:
|
||||
|
||||
- be simple enough to trust under failure;
|
||||
- be audited;
|
||||
- have automatic expiry;
|
||||
- restore normal policy after expiry unless explicitly uninstalled;
|
||||
- not depend on the user agent being healthy.
|
||||
|
||||
## Event Log, Tamper Evidence, And Retention
|
||||
|
||||
Events should be append-only and bounded.
|
||||
|
||||
Recommended initial format: JSON lines with hash chaining.
|
||||
|
||||
```text
|
||||
event_hash = hash(schema_version, sequence, timestamp, event_type, payload, previous_hash)
|
||||
```
|
||||
|
||||
The log should include:
|
||||
|
||||
- policy applications;
|
||||
- state transitions;
|
||||
- violations;
|
||||
- process/monitor failures;
|
||||
- override requests and completions;
|
||||
- review outcomes.
|
||||
|
||||
Retention and storage:
|
||||
|
||||
- rotate logs by size and time;
|
||||
- enforce max disk usage;
|
||||
- define behavior when disk is full;
|
||||
- support export and deletion through delayed admin if enforcement logs are involved;
|
||||
- include schema version and migration path.
|
||||
|
||||
If logging fails:
|
||||
|
||||
- non-critical review notes may be skipped with visible warning;
|
||||
- enforcement/audit events should fail conservative, usually `Locked`, unless doing so would create an unsafe machine lockout;
|
||||
- break-glass must still work even when normal logging is impaired, with best-effort emergency log.
|
||||
|
||||
## Privacy, Ethics, And Data Retention
|
||||
|
||||
This is voluntary self-use software. It must not become bossware, parental control software, or coercive monitoring.
|
||||
|
||||
Principles:
|
||||
|
||||
- local-first storage by default;
|
||||
- no network transmission unless explicitly configured;
|
||||
- minimum necessary observation;
|
||||
- prefer domain over full URL;
|
||||
- prefer window class over title where possible;
|
||||
- treat window titles as sensitive because they can contain private messages, documents, or secrets;
|
||||
- encrypt logs/history at rest if feasible, especially once planner and presence data exist;
|
||||
- provide retention, deletion, and export tools;
|
||||
- make data collection visible to the user;
|
||||
- include first-class `rest` and `exploration` commitments so legitimate openness and recovery are not treated as failure.
|
||||
|
||||
The system should increase agency, not create surveillance anxiety. Presence sensing, camera use, posture detection, and phone pickup detection require a separate opt-in design and ethics review after the core system is useful.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
Failures should have explicit fail-open or fail-closed behavior.
|
||||
|
||||
| Failure | Default behavior |
|
||||
| --- | --- |
|
||||
| User agent crash | Guardian restarts agent; if repeated, enter `Locked` |
|
||||
| User agent crash loop | Enter `Locked`, show recovery instructions |
|
||||
| Guardian crash | systemd restarts guardian; until restored, user agent shows enforcement degraded |
|
||||
| Guardian crash loop | Fail conservative where possible, but keep break-glass available |
|
||||
| ActivityWatch stopped | Guardian restarts it; repeated failure escalates to `Locked` if ActivityWatch is required |
|
||||
| ActivityWatch unavailable | Run degraded if policy does not require it; otherwise refuse `Active` |
|
||||
| ActivityWatch data corrupted | Mark evidence degraded, preserve raw error, continue only if policy allows |
|
||||
| Policy apply failure | Refuse `Active`; stay `Planning` or move `Locked` |
|
||||
| Malformed policy | Reject, keep previous valid policy, escalate after repeated failures |
|
||||
| Disk full/log write failure | Warn; fail conservative for enforcement events; keep break-glass available |
|
||||
| System reboot | Start in `Locked` until state is restored and verified |
|
||||
| Suspend/hibernate | On resume, revalidate timebox and policy; otherwise enter `Locked` |
|
||||
| Clock manipulation | Prefer monotonic timers for timeboxes; record wall-clock anomalies |
|
||||
| OS updates | Require delayed admin or maintenance commitment |
|
||||
| Emergency interruption | Use transition or break-glass depending on severity |
|
||||
| Schema migration failure | Enter `Locked` with recovery instructions |
|
||||
|
||||
The primary invariant is:
|
||||
|
||||
```text
|
||||
Without an active valid commitment, the machine cannot silently enter unconstrained use.
|
||||
```
|
||||
|
||||
## Locked, Planning, Transition, And Review UX
|
||||
|
||||
Locked state should show a small, reliable UI:
|
||||
|
||||
- current status;
|
||||
- reason for lock;
|
||||
- option to enter constrained Planning;
|
||||
- option to review last session if pending;
|
||||
- break-glass instructions that require delayed admin.
|
||||
|
||||
Planning should be useful but constrained:
|
||||
|
||||
- local project/task list;
|
||||
- commitment templates;
|
||||
- recent commitments;
|
||||
- allowed local notes or references;
|
||||
- time limit;
|
||||
- no arbitrary browsing unless a planning policy explicitly allows it.
|
||||
|
||||
Transition should force consciousness into state changes:
|
||||
|
||||
- reason;
|
||||
- expected duration;
|
||||
- return target;
|
||||
- optional note;
|
||||
- timeout behavior.
|
||||
|
||||
Review should be difficult to skip:
|
||||
|
||||
- mark outcome;
|
||||
- rate relevance;
|
||||
- record drift or transition failures;
|
||||
- update commitment/task state;
|
||||
- generate next suggested commitment where useful.
|
||||
|
||||
## Staged Implementation
|
||||
|
||||
### Stage 1a: Commitment Kernel
|
||||
|
||||
- Commitment schema.
|
||||
- PolicySnapshot schema without privileged enforcement.
|
||||
- Runtime and commitment state machines.
|
||||
- Append-only local event log.
|
||||
- Unit tests for legal and illegal transitions.
|
||||
|
||||
### Stage 1b: Session UI And Evidence
|
||||
|
||||
- Session UI using commitment fields.
|
||||
- Review flow.
|
||||
- Activity/window evidence linked to `commitment_id`.
|
||||
- Degraded evidence reporting when ActivityWatch or window metadata is unavailable.
|
||||
|
||||
### Stage 1c: Tier 0 Friction
|
||||
|
||||
- Full-screen or high-priority overlay on violation where feasible.
|
||||
- Dismissal requires deliberate reason entry.
|
||||
- Explicit transition prompts.
|
||||
- Unknown-window/domain behavior based on enforcement level.
|
||||
|
||||
### Stage 2a: Guardian Skeleton
|
||||
|
||||
- systemd system service for guardian.
|
||||
- systemd user service or launcher for agent.
|
||||
- Watchdog restart behavior for user agent and ActivityWatch.
|
||||
- Minimal status reporting.
|
||||
|
||||
### Stage 2b: IPC And Policy Delivery
|
||||
|
||||
- Root-owned Unix domain socket.
|
||||
- JSON-lines protocol.
|
||||
- `ApplyPolicy`, `ReportEvent`, `GetStatus`, `RequestOverride`.
|
||||
- Schema versioning and malformed-message handling.
|
||||
|
||||
### Stage 2c: Domain Blocking
|
||||
|
||||
- nftables or DNS-level policy application.
|
||||
- Domain matching semantics.
|
||||
- Policy rollback on failure.
|
||||
|
||||
### Stage 2d: App/Window Interruption
|
||||
|
||||
- X11 adapter first if current environment is X11.
|
||||
- Compositor-specific Wayland adapters only after verification.
|
||||
- Minimize, overlay, or interrupt based on policy.
|
||||
|
||||
### Stage 2e: Tamper-Evident Logs And Retention
|
||||
|
||||
- Hash-chained events.
|
||||
- Rotation and max disk usage.
|
||||
- Export/delete path.
|
||||
- Log failure handling.
|
||||
|
||||
### Stage 2f: Delayed Admin And Config Lockdown
|
||||
|
||||
- Root-owned policy/config.
|
||||
- Delayed override protocol.
|
||||
- Break-glass command.
|
||||
- Uninstall/rollback tooling.
|
||||
|
||||
### Stage 3a: Project And Task Model
|
||||
|
||||
- Projects.
|
||||
- Tasks.
|
||||
- Next actions.
|
||||
- Recurring responsibilities.
|
||||
|
||||
### Stage 3b: Commitment Templates From Tasks
|
||||
|
||||
- Project defaults.
|
||||
- Work-mode templates.
|
||||
- Task selection during Planning state.
|
||||
- Time-limited context expansion.
|
||||
|
||||
### Stage 3c: Outcome Writeback
|
||||
|
||||
- Session outcomes written back to tasks.
|
||||
- Review suggestions based on drift history.
|
||||
- Next commitment suggestions.
|
||||
|
||||
## Future Direction: Embodied And Presence Sensing
|
||||
|
||||
Presence sensing is not part of the core roadmap. It may be considered later only if:
|
||||
|
||||
- the core system has proven useful;
|
||||
- the user explicitly opts in;
|
||||
- data minimization is defined;
|
||||
- psychological safety is reviewed;
|
||||
- retention and deletion behavior are explicit.
|
||||
|
||||
Potential future events:
|
||||
|
||||
- `desk_absence_started`;
|
||||
- `desk_absence_resolved`;
|
||||
- `posture_warning`;
|
||||
- `phone_pickup_detected`.
|
||||
|
||||
These should be evidence events, not core dependencies.
|
||||
|
||||
## Uninstall And Rollback
|
||||
|
||||
The system must have a clean removal path.
|
||||
|
||||
Uninstall should:
|
||||
|
||||
- disable guardian service;
|
||||
- disable user agent service;
|
||||
- remove nftables or DNS rules;
|
||||
- remove root-owned policy/config files after delayed admin confirmation;
|
||||
- export or delete logs;
|
||||
- restore normal admin behavior if it was modified for AntiDrift;
|
||||
- verify that no blocking policy remains active.
|
||||
|
||||
Rollback should support returning from a failed upgrade to the previous known-good policy and guardian version.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Testing must include bypass resistance, not only happy paths.
|
||||
|
||||
Unit tests:
|
||||
|
||||
- commitment validation;
|
||||
- legal and illegal runtime transitions;
|
||||
- legal and illegal commitment transitions;
|
||||
- PolicySnapshot generation;
|
||||
- allowed context matching;
|
||||
- event hash chaining.
|
||||
|
||||
Property tests:
|
||||
|
||||
- no unconstrained use without valid active commitment;
|
||||
- illegal transitions do not mutate state;
|
||||
- expired policy snapshots are rejected.
|
||||
|
||||
Integration tests:
|
||||
|
||||
- user agent writes state;
|
||||
- guardian reads policy;
|
||||
- simulated window/ActivityWatch events produce expected violations;
|
||||
- malformed policy is rejected;
|
||||
- agent disconnect triggers grace period then `Locked`;
|
||||
- timebox expiry moves to `Review` or `Locked` as configured.
|
||||
|
||||
Adversarial VM/manual tests:
|
||||
|
||||
- kill agent;
|
||||
- stop ActivityWatch;
|
||||
- attempt blocked domain;
|
||||
- malformed policy;
|
||||
- guardian restart;
|
||||
- guardian crash loop;
|
||||
- timebox expiry;
|
||||
- config change attempt;
|
||||
- reboot during active commitment;
|
||||
- suspend/resume during active commitment;
|
||||
- disk full or log write failure.
|
||||
|
||||
Soak and upgrade tests:
|
||||
|
||||
- 24-48 hour run with normal use;
|
||||
- log rotation;
|
||||
- schema migration;
|
||||
- downgrade/rollback;
|
||||
- IPC fuzz tests.
|
||||
|
||||
The first implementation plan should target Stage 1a through Stage 1c while preserving the process boundary and data contracts needed for Stage 2 and planner integration.
|
||||
@@ -1,266 +0,0 @@
|
||||
# AntiDrift Go Reimagining — Design
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
## Purpose
|
||||
|
||||
AntiDrift is being reimagined from its current Rust implementation (~7,500
|
||||
lines) into Go, to become the user's focus operating system on the computer:
|
||||
fast, good-looking, and deeply integrated with AI from the start.
|
||||
|
||||
This is **not a faithful 1:1 port**. The existing domain model and the
|
||||
`commitment-os-design.md` spec remain the north star. The Rust code is a
|
||||
reference, not a thing to replicate line-for-line. The move to Go is an
|
||||
opportunity to shed incidental complexity — most notably the token-heavy
|
||||
event-log replay/revalidation design — while preserving what is genuinely
|
||||
valuable.
|
||||
|
||||
### Why Go, why now
|
||||
|
||||
- **Token efficiency for AI-assisted development.** The current pain is not
|
||||
runtime cost; it is that any AI edit to the core must load
|
||||
`session.rs` (3,475 lines, ~80% replay-validation logic and its tests).
|
||||
Go's smaller idioms plus a redesigned, smaller core directly reduce the
|
||||
context any change requires.
|
||||
- **Predictable LLM codegen.** Go's rigid syntax produces functional code on
|
||||
the first pass with fewer correction loops.
|
||||
- **Runtime fit.** Concurrency and local HTTP serving are first-class, which
|
||||
suits a long-running focus daemon that also talks to AI.
|
||||
|
||||
## What Carries Over vs. What Changes
|
||||
|
||||
**Preserved (high value, ports cleanly):**
|
||||
|
||||
- The domain model: `Commitment`, `PolicySnapshot`, `RuntimeState`,
|
||||
`CommitmentState`, `AllowedContext`, `EnforcementLevel`.
|
||||
- The pure runtime/commitment state machines (`state_machine.rs`) — a near 1:1
|
||||
port.
|
||||
- The `commitment-os-design.md` spec as the conceptual foundation, including
|
||||
"no unchosen transitions" and the staged threat model.
|
||||
- Hash-chained tamper evidence — but relocated to the audit log only.
|
||||
|
||||
**Reimagined:**
|
||||
|
||||
- **Persistence.** Replace replay-everything-and-revalidate-on-startup with an
|
||||
in-memory state-of-truth, a persisted **snapshot**, and an append-only audit
|
||||
log. This removes roughly 3,000 lines (the bulk of `session.rs`).
|
||||
- **UI.** Replace the ratatui TUI with a **local web app** (Gin backend +
|
||||
browser). This is the surface that must "look good."
|
||||
- **AI.** AI is a first-class participant from the start, not a later add-on.
|
||||
|
||||
**Deferred for v1:**
|
||||
|
||||
- The AI **reviewer** role (session-end reflection). The three live roles ship
|
||||
first; the reviewer returns as **M7 — Reflection**, which closes the loop.
|
||||
- Privileged enforcement (guardian, IPC, nftables, delayed admin) — same Stage 2
|
||||
boundary as the original spec. This is the path toward the **entry gate**
|
||||
(see "Destination" in the Roadmap) and is deliberately the last milestone.
|
||||
|
||||
## Process Model
|
||||
|
||||
A single Go binary, `antidriftd`, runs as a **local daemon** and owns all
|
||||
state. The **browser** is its face.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ antidriftd (one Go process) │
|
||||
│ │
|
||||
│ web (Gin) ──HTTP + SSE──▶ browser UI │
|
||||
│ │ │
|
||||
│ session ── statemachine ── domain │
|
||||
│ │ │
|
||||
│ store (snapshot + audit log) │
|
||||
│ evidence (xdotool/X11) │
|
||||
│ ai (CLI backend, async workers) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- The daemon holds live state **in memory** as the single source of truth.
|
||||
- It persists a **snapshot** on every state change (crash/restart recovery).
|
||||
- It appends every significant event to an **append-only audit log** (the
|
||||
tamper-evident, hash-chained trail — for audit and later review, not for
|
||||
state reconstruction).
|
||||
- The browser is stateless: it renders what the daemon pushes over Server-Sent
|
||||
Events (SSE) and POSTs user actions back. No business logic in the browser.
|
||||
|
||||
### Why snapshot instead of replay
|
||||
|
||||
The original Rust design reconstructs all state by replaying the entire event
|
||||
log on startup and re-validating every transition, with a dedicated test per
|
||||
illegal sequence. That is correct and tamper-aware, but it is the single
|
||||
largest source of code and token weight. A snapshot of current state plus an
|
||||
append-only audit trail gives the same recoverability and keeps tamper evidence
|
||||
on the log, at a fraction of the code. State-machine *correctness* is still
|
||||
enforced — by the pure transition functions at the point of transition, tested
|
||||
directly — just not re-litigated on every startup.
|
||||
|
||||
## Architecture: Ports Around a Decision Core
|
||||
|
||||
AntiDrift is a **focus brain**: a decision core surrounded by pluggable
|
||||
interfaces (ports) to the outside world. This is a ports-and-adapters
|
||||
(hexagonal) architecture, and it is the organizing principle the whole system
|
||||
grows along. New capability is almost always "a new port + adapter," not a
|
||||
change to the core.
|
||||
|
||||
The core is layered, and the layering is load-bearing:
|
||||
|
||||
- **Skeleton — deterministic, no I/O** (`domain` + `statemachine`). The rails.
|
||||
Owns what moves are *legal*. The original spec's safety property, "no
|
||||
unchosen transitions," lives here: the system can only ever be in a legal
|
||||
state, reached by a legal move.
|
||||
- **Nervous system — the orchestrator** (`session.Controller`). The single hub.
|
||||
Holds the in-memory state-of-truth, routes signals between ports and the
|
||||
skeleton, persists snapshots, appends to the audit log, and broadcasts.
|
||||
Everything connects through here.
|
||||
- **Cortex — the advisor** (the LLM, via the `ai` port). Powerful *judgment* at
|
||||
the decision points the state machine exposes — sharpen this commitment, is
|
||||
this window drift, nudge me. It informs and proposes; **it can never force an
|
||||
illegal transition.** The LLM is the most powerful adapter, not the kernel.
|
||||
|
||||
"The brain" is all three together. Critically, the state machine — not the LLM
|
||||
— owns transitions; the LLM acts only within the rails the skeleton enforces.
|
||||
|
||||
### Ports
|
||||
|
||||
Each port is a small Go interface with one real adapter (and a fake for tests).
|
||||
|
||||
| Port | Interface | "Answers" | Adapter(s) | Milestone |
|
||||
| ---- | --------- | --------- | ---------- | --------- |
|
||||
| Activity | `evidence.Source` | What am I doing right now? | X11 / xgbutil (was xdotool) | M1 |
|
||||
| Advisor | `ai.Assistant` (`Coach`/`JudgeDrift`/`Nudge`) | What's the smart call here? | `claude`/`codex` CLI | M2–M3 |
|
||||
| Tasks | `tasks.Provider` | What *should* I be doing? | Amazing Marvin (existing `ampy` + marvin MCP) | deferred (M5) |
|
||||
| Knowledge | `knowledge.Source` | Who am I; what are my priorities? | PKM / files | deferred (M6) |
|
||||
| Enforcement / Gate | `enforce.Guard` | Make drift cost something — ultimately, gate the machine on a declared intention | window-minimize (legacy `minimize_other`) → nftables/guardian → entry gate | deferred (M8) |
|
||||
| UI | `web` | Show me; take my input | Gin + browser over SSE | M0, ongoing |
|
||||
|
||||
Persistence (`store`) is infrastructure shared by the orchestrator, not a port.
|
||||
|
||||
The `tasks`, `knowledge`, and `enforce` ports are **named now but built later**
|
||||
— defining them keeps the architecture coherent without expanding near-term
|
||||
scope. We resist designing their interfaces in detail until the milestone that
|
||||
builds them, to avoid speculative abstraction (YAGNI). M1 ships the first real
|
||||
port end-to-end (`evidence.Source` + X11 adapter + fake), establishing the
|
||||
pattern every later port copies.
|
||||
|
||||
## Package Layout
|
||||
|
||||
| Package | Ports from | Size | Purpose |
|
||||
| -------------- | ------------------------ | ------ | ------- |
|
||||
| `domain` | `domain.rs` | small | Commitment, PolicySnapshot, runtime/commitment states, AllowedContext, EnforcementLevel, validation |
|
||||
| `statemachine` | `state_machine.rs` | small | Pure transition functions (1:1 port) |
|
||||
| `session` | reimagined `session.rs` | medium | In-memory controller; drives transitions, snapshots, audit appends; no replay validation |
|
||||
| `store` | `event_log.rs` | small | Snapshot file (current state) + append-only hash-chained audit JSONL |
|
||||
| `evidence` | `window/*` + `context.rs`| small | Active-window snapshot (xdotool/X11), evidence health, allowed-context matching |
|
||||
| `ai` | new | small | `Coach` / `JudgeDrift` / `Nudge` behind one interface; CLI backend |
|
||||
| `web` | new (replaces TUI) | medium | Gin routes, SSE stream, static browser UI |
|
||||
| `tasks` | new (deferred, M5) | small | `Provider` port over current to-do items; Amazing Marvin adapter |
|
||||
| `knowledge` | new (deferred, M6) | small | `Source` port over personal priorities / about-me context |
|
||||
| `enforce` | `window/*` (minimize) | small | `Guard` port; make drift cost something (window-minimize → nftables/guardian) |
|
||||
|
||||
Design constraint: every package stays small and single-purpose so an AI edit
|
||||
loads one focused file, not a monolith. This is the concrete mechanism for the
|
||||
token-efficiency goal.
|
||||
|
||||
## AI Integration
|
||||
|
||||
AI is reached through one narrow interface with a single CLI backend to start:
|
||||
|
||||
```go
|
||||
type Assistant interface {
|
||||
// Planning: turn a vague intent into a concrete commitment.
|
||||
Coach(ctx context.Context, intent string) (domain.Commitment, error)
|
||||
// Live: is the current window on-task for this commitment?
|
||||
JudgeDrift(ctx context.Context, c domain.Commitment, w evidence.WindowSnapshot) (Verdict, error)
|
||||
// Ambient: periodic check-in based on recent activity.
|
||||
Nudge(ctx context.Context, c domain.Commitment, recent []evidence.WindowSnapshot) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
- **Backend (v1):** shell out to `claude`/`codex` with a strict prompt that
|
||||
demands JSON output. Reuses existing CLI auth; no API key plumbing.
|
||||
- **Latency containment** (the CLI is slow, ~seconds, and AI is in the live hot
|
||||
path): all AI calls run in **background goroutines**; the UI never blocks.
|
||||
Drift judgments are **debounced** (no faster than ~10s) and **cached per
|
||||
(commitment, window-class)** so the same window is not re-judged. The UI
|
||||
shows a pending state and updates via SSE when a verdict lands.
|
||||
- **Swap path:** the interface boundary lets an Anthropic API backend (faster,
|
||||
structured, prompt-cached) drop in later without touching callers. Not built
|
||||
in v1.
|
||||
|
||||
The three live roles ship first: planning **coach**, live **drift
|
||||
interceptor**, ambient **nudge**. The reviewer is deferred. The advisor sits at
|
||||
the **cortex** layer of the decision core (see "Architecture: Ports Around a
|
||||
Decision Core"): it proposes and judges at the decision points the state
|
||||
machine exposes, but never owns a transition.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Each milestone is independently shippable and gets its own spec → plan → build
|
||||
cycle. M0–M4 build the core plus the first two ports (activity, advisor) and the
|
||||
UI; M5–M8 add the remaining ports and close the loop, each a small interface +
|
||||
adapter following the pattern M1 establishes.
|
||||
|
||||
**Destination — the gate-first loop.** The end state is an OS-level focus loop:
|
||||
the **Guard** checks for a declared intention *before* the machine is usable,
|
||||
the user commits, works under monitoring, and every cycle ends in
|
||||
**reflection** before returning to the gate (gate → plan → work → reflect →
|
||||
gate). The earlier milestones build "track and advise"; the later ones turn
|
||||
that into "you don't drift in the first place." We get there incrementally — the
|
||||
Guard's privileged entry-gate behavior is deliberately the last and heaviest
|
||||
step (M8), and everything before it is valuable standalone. The runtime state
|
||||
machine already mirrors this loop: `locked` is the gate, `planning`/`active` are
|
||||
declare-and-work, `review` is reflection.
|
||||
|
||||
- **M0 — Walking skeleton.** Daemon + Gin + minimal browser UI; port `domain` +
|
||||
`statemachine`; snapshot persistence; manual commitment → timebox → end.
|
||||
Proves the full stack end-to-end. No AI, no window tracking.
|
||||
- **M1 — Evidence & audit.** X11 (xgbutil) active-window tracking via the
|
||||
`evidence.Source` port, evidence health, per-window time stats, append-only
|
||||
hash-chained audit log, live SSE updates. Establishes the port pattern.
|
||||
- **M2 — AI planning coach.** `ai` port + CLI backend; "sharpen this
|
||||
commitment" in the Planning view.
|
||||
- **M3 — Drift interceptor + ambient nudge.** Allowed-context matching + live
|
||||
AI drift judgment (debounced/cached) + violation friction UI.
|
||||
- **M4 — Look good.** A real design pass on the web UI.
|
||||
- **M5 — Tasks port.** `tasks.Provider` over current to-do items; Amazing Marvin
|
||||
adapter. Pull the day's commitments from real tasks.
|
||||
- **M6 — Knowledge port.** `knowledge.Source` over personal priorities and
|
||||
about-me context, feeding the advisor richer grounding.
|
||||
- **M7 — Reflection.** The deferred AI **reviewer** role, promoted into the main
|
||||
loop: a session-end reflection that reads the audit trail and per-window stats
|
||||
(built in M1) to summarize what happened and feed the next planning cycle.
|
||||
Closes the "Focus OS Reflection" step and makes the loop self-reinforcing.
|
||||
- **M8 — Enforcement & gate.** `enforce.Guard`: make drift cost something,
|
||||
starting with window-minimize (porting legacy `minimize_other`) and the
|
||||
nftables/DNS path, building toward the **entry gate** — the Guard checking for
|
||||
a declared intention before the machine is usable. The privileged guardian
|
||||
process, root-owned IPC, and break-glass keep the original Stage 2 threat
|
||||
boundary and are the final, heaviest step.
|
||||
|
||||
The first sub-project to brainstorm and spec in detail is **M0**.
|
||||
|
||||
## Repo Strategy
|
||||
|
||||
- New Go module at the repository root.
|
||||
- Move the existing Rust into a `legacy/` directory (or a `rust` branch) so it
|
||||
remains available as reference while the Go code becomes the front door.
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
"v1" here means the first shippable arc, **M0–M4** (core + activity/advisor
|
||||
ports + UI). The items below are deferred past it; some are now named ports with
|
||||
their own later milestones (see Roadmap), others remain fully out of scope.
|
||||
|
||||
- **Tasks, knowledge, and enforcement ports** — named in the architecture and
|
||||
slotted as M5–M8, but not built in v1. The `enforce.Guard` port starts with
|
||||
window-minimize and builds toward the entry gate; its **privileged** adapters
|
||||
(guardian process, root-owned Unix socket IPC, nftables/DNS domain blocking,
|
||||
delayed admin, break-glass) remain out of scope until M8 and keep the original
|
||||
Stage 2 threat boundary.
|
||||
- AI reviewer / session-end reflection — now scheduled as **M7 — Reflection**.
|
||||
- Wayland compositor adapters beyond the existing degraded reporting.
|
||||
- Planner/project model and outcome writeback (beyond the M5 tasks port).
|
||||
- Presence sensing.
|
||||
|
||||
These remain governed by `commitment-os-design.md` and may return as later
|
||||
milestones.
|
||||
@@ -1,209 +0,0 @@
|
||||
# M0 — Walking Skeleton Design
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
Parent design: `2026-05-31-go-focus-os-design.md`
|
||||
|
||||
## Purpose
|
||||
|
||||
M0 proves the entire Go stack end-to-end with the smallest possible surface: a
|
||||
single Go daemon (`antidriftd`) that serves a local web UI, drives one manual
|
||||
commitment through the ported state machine, and persists a snapshot that
|
||||
survives restart.
|
||||
|
||||
M0 explicitly excludes AI, active-window tracking, and the hash-chained audit
|
||||
log (those arrive in M1+). What M0 establishes is the real architecture — the
|
||||
daemon process model, the ported domain/state-machine, snapshot persistence,
|
||||
and the SSE sync channel — so later milestones add features to a working spine
|
||||
rather than scaffolding.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Port `domain` (Commitment, runtime/commitment states, EnforcementLevel,
|
||||
minimal PolicySnapshot, validation).
|
||||
- Port `statemachine` (pure runtime/commitment transition functions).
|
||||
- `session.Controller`: in-memory state of truth behind a mutex; snapshot on
|
||||
every change.
|
||||
- `store`: snapshot load/save as JSON.
|
||||
- `web`: Gin server with the M0 HTTP surface and an SSE stream.
|
||||
- A single-page vanilla-JS browser UI covering the M0 state flow.
|
||||
- Unit tests for domain, statemachine, session; httptest tests for web.
|
||||
|
||||
Out of scope (deferred):
|
||||
|
||||
- AI of any kind (M2+).
|
||||
- Active-window tracking and evidence health (M1).
|
||||
- Hash-chained append-only audit log (M1).
|
||||
- Allowed-context matching and violation friction (M3).
|
||||
- Transition (break) flow, admin override, planner — full machine edges beyond
|
||||
the M0 subset.
|
||||
- Visual design polish (M4).
|
||||
|
||||
## State Flow
|
||||
|
||||
M0 exercises a subset of the full runtime state machine:
|
||||
|
||||
```
|
||||
Locked ──Plan──▶ Planning ──Start──▶ Active ──Complete / timebox expiry──▶ Review ──End──▶ Locked
|
||||
```
|
||||
|
||||
All transitions go through the ported pure transition functions, so behavior is
|
||||
correct from day one even though fewer edges are exercised. Mapping to the
|
||||
ported actions:
|
||||
|
||||
- Locked → Planning: `EnterPlanning`
|
||||
- Planning → Active: `Activate { policy_accepted: true }` (commitment also
|
||||
`Activate`s draft → active)
|
||||
- Active → Review: `CompleteForReview` (triggered by user "Complete" or by
|
||||
server-side timebox expiry)
|
||||
- Review → Locked: `EndWorkPeriod`
|
||||
|
||||
## Components
|
||||
|
||||
### `domain`
|
||||
|
||||
Direct port of the valuable Rust types:
|
||||
|
||||
- `Commitment` (id, createdAt, source, next action, success condition, timebox
|
||||
seconds, state) with `NewManual(...)` validation: non-empty next action,
|
||||
non-empty success condition, non-zero timebox.
|
||||
- `RuntimeState` (Locked, Planning, Active, Transition, Review, AdminOverride)
|
||||
and `CommitmentState` (Draft, Active, Paused, Completed, Abandoned, Violated)
|
||||
— full enums, even though M0 uses a subset, so later milestones need no
|
||||
changes here.
|
||||
- `EnforcementLevel` (Observe, Warn, Block, Locked).
|
||||
- `PolicySnapshot`: minimal — enough to represent the accepted policy that gates
|
||||
`Activate`. Full enforcement fields can stay zero/empty in M0.
|
||||
- IDs use UUIDv7 (or equivalent monotonic unique id); JSON tags use snake_case
|
||||
to match the existing on-disk vocabulary.
|
||||
|
||||
### `statemachine`
|
||||
|
||||
Port of `state_machine.rs`:
|
||||
|
||||
- `TransitionRuntime(current RuntimeState, action RuntimeAction) (RuntimeState, error)`
|
||||
- `TransitionCommitment(current CommitmentState, action CommitmentAction) (CommitmentState, error)`
|
||||
- Illegal transitions return a typed error identifying current state + action.
|
||||
Pure functions, no I/O.
|
||||
|
||||
### `session.Controller`
|
||||
|
||||
- Holds `runtimeState` and `activeCommitment` in memory, guarded by a
|
||||
`sync.Mutex`, as the single source of truth.
|
||||
- Methods: `EnterPlanning()`, `StartManualCommitment(nextAction, successCond
|
||||
string, timebox time.Duration)`, `Complete()`, `End()`.
|
||||
- Each method applies the relevant transition(s), updates in-memory state, and
|
||||
persists a snapshot via `store`. On any transition error, state is left
|
||||
unchanged and the error is returned.
|
||||
- Exposes a read method returning a snapshot-shaped view for broadcasting.
|
||||
|
||||
### `store`
|
||||
|
||||
- Snapshot is the current state: runtime state + active commitment (+ deadline).
|
||||
- `Load(path) (Snapshot, error)` — missing file yields a default `Locked`
|
||||
snapshot, not an error.
|
||||
- `Save(path, Snapshot) error` — atomic write (temp file + rename) to
|
||||
`~/.antidrift/state.json`, creating the directory if needed.
|
||||
- No hash chaining in M0; that belongs to the M1 audit log.
|
||||
|
||||
### `web`
|
||||
|
||||
- Gin server bound to `localhost:7777`.
|
||||
- Holds the `session.Controller` and an SSE broadcaster (set of subscriber
|
||||
channels).
|
||||
- Each mutating handler: acquire controller mutex → apply transition → persist
|
||||
snapshot → broadcast new state to all SSE subscribers → return.
|
||||
|
||||
## Daemon Behavior
|
||||
|
||||
On start, `antidriftd`:
|
||||
|
||||
1. Loads the snapshot (or starts `Locked`).
|
||||
2. If the loaded state is `Active` with a future deadline, re-arms the expiry
|
||||
timer; if the deadline has already passed, transitions to `Review`.
|
||||
3. Starts Gin on `localhost:7777`.
|
||||
4. Attempts to open the default browser at that URL (best-effort; logs and
|
||||
continues if it fails).
|
||||
|
||||
Timebox expiry is **server-authoritative**: on entering `Active`, the daemon
|
||||
arms a `time.AfterFunc` at the deadline that fires `Active → Review` and
|
||||
broadcasts. The browser countdown is cosmetic and derived from the deadline
|
||||
timestamp in the state payload.
|
||||
|
||||
## HTTP Surface
|
||||
|
||||
| Method | Route | Body | Effect |
|
||||
| ------ | ------------- | ------------------------------------------------- | ------ |
|
||||
| GET | `/` | — | Serves the single-page UI |
|
||||
| GET | `/events` | — | SSE stream; emits the current state immediately, then on every change |
|
||||
| POST | `/planning` | — | Locked → Planning |
|
||||
| POST | `/commitment` | `{next_action, success_condition, timebox_secs}` | Planning → Active (validates; 400 on invalid) |
|
||||
| POST | `/complete` | — | Active → Review |
|
||||
| POST | `/end` | — | Review → Locked |
|
||||
|
||||
State payload (SSE `data:` and POST responses) is JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime_state": "active",
|
||||
"commitment": {
|
||||
"next_action": "Port the domain package",
|
||||
"success_condition": "domain tests pass",
|
||||
"timebox_secs": 1500,
|
||||
"deadline_unix_secs": 1748725200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`commitment` is null when there is no active commitment. Invalid transitions
|
||||
requested via HTTP return 409 (illegal transition) or 400 (invalid input); state
|
||||
is unchanged.
|
||||
|
||||
## Browser UI
|
||||
|
||||
Single HTML page served from `/`, using vanilla JavaScript with no build step
|
||||
(no bundler, no framework) to keep it dependency-free and token-light. It:
|
||||
|
||||
- Subscribes to `/events` via `EventSource`.
|
||||
- Renders exactly one view based on `runtime_state`:
|
||||
- **Locked** — status + a "Plan" button (POST `/planning`).
|
||||
- **Planning** — a form with next action, success condition, and minutes;
|
||||
"Start" is disabled until all three are valid (POST `/commitment`).
|
||||
- **Active** — next action, success condition, a live countdown derived from
|
||||
`deadline_unix_secs`, and a "Complete" button (POST `/complete`).
|
||||
- **Review** — a short summary of the just-ended commitment and an "End"
|
||||
button (POST `/end`).
|
||||
- Is a pure renderer of pushed state; it holds no authoritative state of its
|
||||
own. Styling is clean and legible but not the focus — the real visual pass is
|
||||
M4.
|
||||
|
||||
## Testing
|
||||
|
||||
- `domain`: validation rejects empty next action / success condition / zero
|
||||
timebox; JSON round-trips with snake_case tags.
|
||||
- `statemachine`: legal transitions for the M0 subset succeed; representative
|
||||
illegal transitions (e.g. Locked → Active) return typed errors. Port the
|
||||
intent of the existing Rust tests.
|
||||
- `session`: planning → start → complete → end happy path drives the expected
|
||||
states; snapshot save/load round-trips and restores the controller.
|
||||
- `web`: `httptest` checks that POST `/planning` then POST `/commitment` moves
|
||||
the controller to Active, that `/commitment` with invalid input returns 400,
|
||||
and that `/events` emits a state payload.
|
||||
|
||||
## Done When
|
||||
|
||||
`go run ./cmd/antidriftd` opens a browser; the user creates a commitment,
|
||||
watches the timebox count down, completes it (or lets it expire into Review),
|
||||
and ends the session back to Locked. Killing and restarting the daemon restores
|
||||
the persisted state from `~/.antidrift/state.json`. `go test ./...` passes.
|
||||
|
||||
## Repo Setup (first task of M0)
|
||||
|
||||
- Initialize the Go module at the repository root.
|
||||
- Move the existing Rust sources into `legacy/` so they remain available as
|
||||
reference (Cargo.toml, src/, etc.), keeping the shared `docs/` at the root.
|
||||
- Code layout: `cmd/antidriftd/` (main), `internal/domain`,
|
||||
`internal/statemachine`, `internal/session`, `internal/store`,
|
||||
`internal/web` (with the static UI under `internal/web/static`).
|
||||
@@ -1,381 +0,0 @@
|
||||
# M1 — Evidence & Audit Design
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
Parent design: `2026-05-31-go-focus-os-design.md`
|
||||
|
||||
## Purpose
|
||||
|
||||
M1 gives the daemon eyes and a memory. It adds a continuous active-window
|
||||
sensor, a two-tier evidence store, and live updates of what you are doing — then
|
||||
seals each finished session into a tamper-evident, hash-chained audit trail.
|
||||
|
||||
M1 is **observe & record only**. It makes no judgment about whether the current
|
||||
window is on-task; that is the advisor's job in M3. M1 is honest
|
||||
instrumentation: the trustworthy foundation that later milestones read from.
|
||||
|
||||
In the architecture of the parent design, M1 builds the first real **port** end
|
||||
to end — the activity port (`evidence.Source`) with its X11 adapter and a fake
|
||||
for tests — establishing the interface + adapter + fake pattern every later port
|
||||
copies.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- `evidence` package: an X11 (xgbutil) active-window sensor behind a `Source`
|
||||
interface, pushing a `WindowSnapshot` on every focus change; evidence health;
|
||||
the legacy title-scrubbing regex as a tested `ScrubTitle` function.
|
||||
- `store` extensions: a per-session raw focus-event log (`sessions/<id>.jsonl`,
|
||||
appended live, pruned after a retention window) and a permanent hash-chained
|
||||
audit log (`audit.jsonl`, one linked `SessionSummary` per completed session).
|
||||
- `session.Controller` extensions: in-memory per-session evidence stats, an
|
||||
injectable clock, focus accumulation while `Active`, crash-recovery replay,
|
||||
and writing the hashed summary at session end.
|
||||
- `web` extensions: the SSE payload carries an `evidence` object; the Active and
|
||||
Review views surface current window, per-bucket time, context-switch count,
|
||||
and an evidence-health indicator. A broadcast callback registered on the
|
||||
controller so every state change (focus, expiry, user action) fans out over
|
||||
one path.
|
||||
- Unit tests for `evidence` (scrub + interface), `store` (audit chain +
|
||||
evidence log), `session` (accumulation via fake source + injectable clock),
|
||||
and `web` (evidence payload over SSE).
|
||||
|
||||
Out of scope (deferred):
|
||||
|
||||
- Any AI / drift judgment / on-task vs off-task classification (M2–M3).
|
||||
- Allowed-context matching and violation friction (M3).
|
||||
- Enforcement of any kind, including window-minimize (M7).
|
||||
- Wayland active-window support beyond degraded `Unavailable` reporting.
|
||||
- Visual design polish (M4) — M1 surfaces data legibly, nothing more.
|
||||
|
||||
## Architecture Fit
|
||||
|
||||
Per the parent design's "ports around a decision core":
|
||||
|
||||
- **Skeleton** (`domain`, `statemachine`) is unchanged by M1.
|
||||
- **Nervous system** (`session.Controller`) gains evidence ownership: it
|
||||
receives sensor events, decides relevance by current runtime state,
|
||||
accumulates stats, persists raw events, and writes the audit summary. It
|
||||
remains the single hub.
|
||||
- **Activity port** (`evidence.Source`) is new — a dumb sensor that makes no
|
||||
decisions. `session` decides what each event means.
|
||||
|
||||
The sensor never judges; the orchestrator never talks to X11 directly. This is
|
||||
the port boundary M1 exists to establish.
|
||||
|
||||
## The Two-Tier Evidence Model
|
||||
|
||||
Window focus can change dozens of times a minute. Keeping every focus event in
|
||||
the permanent hash chain forever would bloat it. Discarding detail loses the
|
||||
ability to compute things like context-switch frequency. M1 resolves this with
|
||||
two tiers:
|
||||
|
||||
1. **Raw, per-session, disposable.** Every focus change is appended live to
|
||||
`sessions/<session-id>.jsonl` as it happens (crash-durable). This is the
|
||||
firehose: full titles, millisecond timestamps. It is the recovery source for
|
||||
in-memory stats and the basis for end-of-session analytics. It is **pruned
|
||||
after 30 days**.
|
||||
2. **Summarized, permanent, tamper-evident.** At session end the raw stream is
|
||||
rolled up into one `SessionSummary` (per-bucket totals, switch count,
|
||||
duration, outcome) which is hash-chained into `audit.jsonl` and **kept
|
||||
forever**. The chain is coarse — one linked entry per session — so tamper
|
||||
evidence is cheap and the log stays small.
|
||||
|
||||
## Components
|
||||
|
||||
### `evidence` (new package)
|
||||
|
||||
A dumb X11 sensor. One long-lived xgbutil connection is opened at daemon start
|
||||
and kept open for the whole process. It subscribes to `PropertyNotify` on the
|
||||
root window's `_NET_ACTIVE_WINDOW` and emits a `WindowSnapshot` on every active
|
||||
window change (and once immediately with the current window). It makes no
|
||||
relevance decisions.
|
||||
|
||||
```go
|
||||
type EvidenceHealth struct {
|
||||
Available bool
|
||||
Reason string // empty when Available; populated when not
|
||||
}
|
||||
|
||||
type WindowSnapshot struct {
|
||||
Title string // full _NET_WM_NAME (raw; used in live view + raw log)
|
||||
Class string // WM_CLASS
|
||||
Health EvidenceHealth
|
||||
}
|
||||
|
||||
type Source interface {
|
||||
// Watch runs until ctx is cancelled, invoking onChange on every active
|
||||
// window change, and once immediately with the current window.
|
||||
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||
}
|
||||
```
|
||||
|
||||
- Real implementation `x11Source` uses xgbutil:
|
||||
`ewmh.ActiveWindowGet` → `ewmh.WmNameGet` (title) + `icccm.WmClassGet` (class).
|
||||
- On X failure, no active window, or unset `DISPLAY`:
|
||||
`Health{Available: false, Reason: "..."}` with empty title/class. This mirrors
|
||||
the legacy degraded reporting and covers Wayland implicitly.
|
||||
- `ScrubTitle(string) string` is the legacy digit/percent regex
|
||||
(`-?\d+([:.]\d+)+%?`) ported as its own tested function. Used to compute
|
||||
bucket keys; the raw log keeps the unscrubbed title.
|
||||
|
||||
### `store` (extended)
|
||||
|
||||
Two new files beside `snapshot.go`. `store` is infrastructure, not a port.
|
||||
|
||||
`store/audit.go` — the permanent hash-chained log at `~/.antidrift/audit.jsonl`:
|
||||
|
||||
```go
|
||||
type BucketTotal struct {
|
||||
Class string `json:"class"`
|
||||
Title string `json:"title"` // scrubbed
|
||||
Seconds int64 `json:"seconds"`
|
||||
}
|
||||
|
||||
type SessionSummary struct {
|
||||
Seq int `json:"seq"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
SessionID string `json:"session_id"`
|
||||
NextAction string `json:"next_action"`
|
||||
SuccessCond string `json:"success_condition"`
|
||||
Outcome string `json:"outcome"` // "completed" | "expired"
|
||||
StartedUnix int64 `json:"started_unix"`
|
||||
EndedUnix int64 `json:"ended_unix"`
|
||||
SwitchCount int `json:"switch_count"`
|
||||
Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func AppendSession(path string, s SessionSummary) error
|
||||
func VerifyChain(path string) error
|
||||
```
|
||||
|
||||
- `AppendSession` reads the last line's `Hash` as this entry's `PrevHash`
|
||||
(genesis = 64 hex zeros), assigns `Seq = lastSeq + 1`, computes
|
||||
`Hash = SHA-256(prevHash || canonicalJSON(fields-except-Hash))`, and appends
|
||||
one JSON line atomically (append + fsync). Canonical serialization uses the
|
||||
struct with `Hash` zeroed and stable field order (encoding/json is stable for
|
||||
structs).
|
||||
- `VerifyChain` re-walks every line: recompute each hash, confirm each
|
||||
`PrevHash` equals the prior `Hash`, confirm `Seq` is contiguous. Returns an
|
||||
error naming the first broken line; nil if intact or empty.
|
||||
|
||||
`store/evidence_log.go` — the per-session raw stream at
|
||||
`~/.antidrift/sessions/<session-id>.jsonl`:
|
||||
|
||||
```go
|
||||
type FocusEvent struct {
|
||||
AtUnixMillis int64 `json:"at_unix_millis"`
|
||||
Class string `json:"class"`
|
||||
Title string `json:"title"` // full, unscrubbed
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func AppendFocus(dir, sessionID string, e FocusEvent) error
|
||||
func ReplaySession(dir, sessionID string) ([]FocusEvent, error)
|
||||
func PruneOlderThan(dir string, age time.Duration, now time.Time) error
|
||||
```
|
||||
|
||||
- `AppendFocus` opens the session file `O_APPEND|O_CREATE`, writes one JSON
|
||||
line, closes. Creates `sessions/` if needed.
|
||||
- `ReplaySession` reads all events in order (used to rebuild stats after a
|
||||
crash).
|
||||
- `PruneOlderThan` deletes session files whose modification time is older than
|
||||
`now - age`. `now` is injected for testability.
|
||||
|
||||
### `session.Controller` (extended)
|
||||
|
||||
Gains:
|
||||
|
||||
- `clock func() time.Time` — injectable; defaults to `time.Now`. Used for all
|
||||
accumulation and timestamps so tests are deterministic.
|
||||
- `stats *EvidenceStats` — in-memory, current session only (nil when not in a
|
||||
live session).
|
||||
- `onChange func()` — a notification callback the web layer registers; invoked
|
||||
after any state change so the browser is pushed fresh state.
|
||||
- A reference to the store paths (audit file, sessions dir).
|
||||
|
||||
```go
|
||||
type bucketKey struct{ Class, Title string } // Title is scrubbed
|
||||
|
||||
type EvidenceStats struct {
|
||||
SessionID string
|
||||
StartedUnix int64
|
||||
Buckets map[bucketKey]time.Duration
|
||||
SwitchCount int
|
||||
Current evidence.WindowSnapshot
|
||||
lastFocusAt time.Time
|
||||
lastKey bucketKey
|
||||
hasLast bool
|
||||
}
|
||||
```
|
||||
|
||||
`RecordWindow(snap evidence.WindowSnapshot)`:
|
||||
|
||||
1. Lock. Always update `c.latestWindow = snap` (for the live indicator outside
|
||||
Active).
|
||||
2. If `runtimeState != Active` or `stats == nil`: fire `onChange`; return.
|
||||
(Tracked for display, not accounted.)
|
||||
3. `now := c.clock()`. If `hasLast`: add `now - lastFocusAt` to
|
||||
`Buckets[lastKey]`. (If the prior snapshot was unavailable, `lastKey` is the
|
||||
reserved `(evidence unavailable)` key.)
|
||||
4. Append a `FocusEvent` to the session log.
|
||||
5. Compute the new key: unavailable snapshot → reserved key
|
||||
`{Class: "", Title: "(evidence unavailable)"}`; else
|
||||
`{snap.Class, ScrubTitle(snap.Title)}`. If `hasLast && newKey != lastKey`,
|
||||
increment `SwitchCount`.
|
||||
6. Set `lastKey = newKey`, `lastFocusAt = now`, `hasLast = true`,
|
||||
`Current = snap`. Fire `onChange`.
|
||||
|
||||
Lifecycle hooks (all under the existing mutex):
|
||||
|
||||
- **`StartManualCommitment`** (→ Active): mint `session_id` (UUIDv7), create
|
||||
`stats` with `StartedUnix = clock()`, seed `Current`/`lastKey`/`lastFocusAt`
|
||||
from `latestWindow` (so the first segment counts from start), `hasLast = true`.
|
||||
Persist snapshot (now carrying `session_id`).
|
||||
- **`Complete`** / **timebox expiry** (→ Review): flush final segment
|
||||
(`clock() - lastFocusAt` into `lastKey`), freeze `stats` (stop accounting).
|
||||
Consistent with M0's "freeze active time during review."
|
||||
- **`End`** (→ Locked): build `SessionSummary` from frozen `stats`
|
||||
(`Outcome` = "completed" if user-completed, "expired" if timebox fired —
|
||||
tracked via a field set at the Active→Review transition), `AppendSession` to
|
||||
the audit chain, then clear `stats`.
|
||||
|
||||
Read view: `State()` is extended to include an evidence projection (current
|
||||
window, buckets sorted desc, switch count, health) for the SSE payload.
|
||||
|
||||
### Crash Recovery
|
||||
|
||||
The snapshot (`state.json`) gains `session_id` and `outcome_pending` fields.
|
||||
|
||||
On startup, after loading the snapshot:
|
||||
|
||||
- Run `store.PruneOlderThan(sessionsDir, 30*24*time.Hour, time.Now())`.
|
||||
- If runtime state is `Active` with a `session_id`: `ReplaySession` the raw log
|
||||
and rebuild `EvidenceStats` exactly (sum segments between consecutive events;
|
||||
the final open segment resumes from the last event's timestamp). The raw log
|
||||
is the recovery source of truth for stats; the snapshot only points at it.
|
||||
- The M0 deadline re-arm / expire-to-Review behavior is unchanged and runs
|
||||
after stats are rebuilt.
|
||||
|
||||
### `web` (extended)
|
||||
|
||||
- At startup the `Server` registers `ctrl.SetOnChange(func(){ broadcast() })`.
|
||||
Every state change — focus update, timebox expiry, user action — flows out
|
||||
through this single path. This also moves M0's expiry broadcast onto the same
|
||||
callback rather than an inline broadcast.
|
||||
- The SSE/POST payload gains an `evidence` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime_state": "active",
|
||||
"commitment": { "next_action": "...", "success_condition": "...",
|
||||
"timebox_secs": 1500, "deadline_unix_secs": 1748725200 },
|
||||
"evidence": {
|
||||
"available": true,
|
||||
"reason": "",
|
||||
"current": { "class": "code", "title": "m1 design - antidrift" },
|
||||
"switch_count": 7,
|
||||
"buckets": [
|
||||
{ "class": "code", "title": "antidrift", "seconds": 540 },
|
||||
{ "class": "firefox", "title": "docs", "seconds": 120 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`evidence` is null when there is no live session (Locked/Planning). In Review it
|
||||
carries the frozen stats.
|
||||
|
||||
### Browser UI (data, not polish)
|
||||
|
||||
The page stays a pure renderer of pushed state. Additions:
|
||||
|
||||
- **Active view:** current window line (`class · title`); a per-bucket time
|
||||
breakdown list (sorted desc, `mm:ss`); the context-switch count; an
|
||||
evidence-health pill — green "tracking" when available, amber
|
||||
"evidence unavailable: <reason>" when not.
|
||||
- **Review view:** the session summary about to be hashed — total active time,
|
||||
switch count, top buckets.
|
||||
- Visual treatment is minimal and legible; the real design pass is M4.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
X server ──PropertyNotify──▶ evidence.x11Source.Watch (goroutine)
|
||||
│ onChange(WindowSnapshot)
|
||||
▼
|
||||
session.Controller.RecordWindow
|
||||
│ (accumulate if Active) │ append raw event
|
||||
▼ ▼
|
||||
EvidenceStats (memory) sessions/<id>.jsonl
|
||||
│ onChange()
|
||||
▼
|
||||
web broadcast ──SSE──▶ browser
|
||||
...
|
||||
End: SessionSummary ──hash-chain──▶ audit.jsonl (permanent)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Evidence unavailable** (X error, no active window, Wayland): snapshot has
|
||||
`Health.Available = false`; time during the gap accrues to the reserved
|
||||
`(evidence unavailable)` bucket so totals always equal wall-clock duration.
|
||||
The watch goroutine logs and continues; it never crashes the daemon.
|
||||
- **Session log append failure:** logged; accounting continues in memory (the
|
||||
raw log is best-effort detail, not the state of truth). Crash recovery for
|
||||
that session would be incomplete, which is acceptable for disposable detail.
|
||||
- **Audit append failure at End:** logged and surfaced; the transition to Locked
|
||||
still completes (state integrity over audit completeness). A retry-on-next-
|
||||
start is out of scope.
|
||||
- **Corrupt audit chain:** `VerifyChain` reports the first broken line; M1 only
|
||||
exposes verification, it does not auto-repair.
|
||||
|
||||
## Testing
|
||||
|
||||
- `evidence`: `ScrubTitle` table tests porting the legacy regex cases
|
||||
(percentages, ratios, leading sign, plain titles untouched). The `x11Source`
|
||||
sits behind `Source`; a `//go:build` integration smoke test queries the live
|
||||
server and is skipped when `DISPLAY` is unset.
|
||||
- `store/audit`: append N summaries then `VerifyChain` passes; genesis
|
||||
`PrevHash` is 64 zeros; `Seq` is contiguous; a tamper test mutates a middle
|
||||
line's field and asserts `VerifyChain` names that line.
|
||||
- `store/evidence_log`: `AppendFocus` then `ReplaySession` round-trips event
|
||||
order/values; `PruneOlderThan` deletes only files older than the cutoff
|
||||
(modification time controlled).
|
||||
- `session`: a `fakeSource` plus injectable `clock` drive scripted focus
|
||||
sequences with controlled timestamps. Assert: bucket totals and switch count;
|
||||
events outside Active are not accounted; unavailable snapshots accrue to the
|
||||
reserved bucket; crash-replay rebuilds identical stats; `End` writes a
|
||||
summary whose buckets/switch-count match and which extends the chain.
|
||||
- `web`: httptest confirms `/events` emits an evidence-bearing payload and that
|
||||
a simulated focus change pushes an updated payload.
|
||||
|
||||
## Done When
|
||||
|
||||
`go run ./cmd/antidriftd`: start a commitment, switch between a few windows, and
|
||||
watch the Active view update live with the current window, per-app time, and
|
||||
switch count. Let it complete (or expire) into Review and see the session
|
||||
summary. End it; `audit.jsonl` gains one hash-chained line and `VerifyChain`
|
||||
passes. Kill the daemon mid-session and restart: the per-window stats are
|
||||
rebuilt from the session log and tracking resumes. Session files older than 30
|
||||
days are gone on startup. `go test ./...` passes.
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `internal/evidence/evidence.go` (types, `Source`, `ScrubTitle`)
|
||||
- Create: `internal/evidence/x11.go` (`x11Source`, xgbutil; build-tagged)
|
||||
- Create: `internal/evidence/evidence_test.go`
|
||||
- Create: `internal/store/audit.go`, `internal/store/evidence_log.go`
|
||||
- Create: `internal/store/audit_test.go`, `internal/store/evidence_log_test.go`
|
||||
- Modify: `internal/store/store.go` (snapshot gains `session_id`,
|
||||
`outcome_pending`)
|
||||
- Modify: `internal/session/session.go` (clock, stats, RecordWindow, lifecycle
|
||||
hooks, onChange, recovery), `internal/session/session_test.go`
|
||||
- Modify: `internal/web/web.go` (SetOnChange wiring, evidence payload),
|
||||
`internal/web/web_test.go`, `internal/web/static/index.html`
|
||||
- Modify: `cmd/antidriftd/main.go` (construct `evidence.Source`, start `Watch`,
|
||||
wire to controller)
|
||||
- Modify: `go.mod` (add `github.com/jezek/xgb`, `github.com/jezek/xgbutil`)
|
||||
@@ -1,447 +0,0 @@
|
||||
# M2 — AI Planning Coach — Design
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
## Purpose
|
||||
|
||||
M2 adds the first AI capability to AntiDrift: a **planning coach**. In the
|
||||
Planning view, the user types one rough intent ("work on the quarterly report"),
|
||||
presses **Sharpen**, and an AI coach proposes a structured commitment —
|
||||
`next_action`, `success_condition`, and a `timebox` — that pre-fills the existing
|
||||
three Planning inputs for the user to edit and accept.
|
||||
|
||||
This establishes the `ai` port (the **cortex** layer of the decision core) and
|
||||
the CLI backend, the pattern every later AI role (drift interceptor, nudge,
|
||||
reflection) will reuse. The coach **proposes**; the user still drives the
|
||||
existing `/commitment` transition. The LLM never owns a state transition.
|
||||
|
||||
AI is **strictly additive**: if the coach is unavailable, slow, or returns
|
||||
garbage, the three manual Planning inputs remain fully usable. This mirrors the
|
||||
evidence-health degradation pattern established in M1.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope (M2):**
|
||||
|
||||
- A new `ai` package with a pluggable CLI **backend** abstraction and **two real
|
||||
adapters from day one: `claude` and `codex`**.
|
||||
- A backend-agnostic **`Coach`** capability that turns a free-text intent into a
|
||||
validated `Proposal`.
|
||||
- Async, SSE-driven delivery: the coach runs in a background goroutine; the UI
|
||||
shows a pending state and updates when the proposal lands.
|
||||
- Graceful degradation on every failure path (missing CLI, timeout, malformed
|
||||
output, no backend wired).
|
||||
- Planning-view UI: an intent box + Sharpen button that pre-fills the existing
|
||||
inputs from the proposal.
|
||||
|
||||
**Out of scope (deferred):**
|
||||
|
||||
- The `JudgeDrift` and `Nudge` roles — they join the `ai` interface in **M3**.
|
||||
M2 builds only `Coach` (YAGNI).
|
||||
- An Anthropic API backend — the interface boundary allows it later without
|
||||
touching callers; not built now.
|
||||
- Any change to the commitment/runtime state machine. The coach produces a
|
||||
draft; activation still goes through the existing `StartManualCommitment`
|
||||
path.
|
||||
- Persisting the proposal. It is ephemeral pre-commitment advice (see
|
||||
"Ephemeral state").
|
||||
|
||||
## Architecture
|
||||
|
||||
M2 follows the established ports-and-adapters shape. The `ai` package is the new
|
||||
**Advisor** port; `claude` and `codex` are its adapters; `session.Controller`
|
||||
(the nervous system) orchestrates the async call and broadcasts; the browser
|
||||
renders. The coach sits at the **cortex** layer: it proposes at a decision point
|
||||
the state machine exposes (planning), but never forces a transition.
|
||||
|
||||
### The `ai` package — two layers
|
||||
|
||||
The pluggability requirement is met by separating *what we ask* from *how we
|
||||
reach a CLI*.
|
||||
|
||||
**Layer 1 — `Backend` (the pluggable adapter).**
|
||||
|
||||
```go
|
||||
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
||||
// and arguments they run.
|
||||
type Backend interface {
|
||||
// Run sends prompt to the CLI and returns its raw stdout.
|
||||
Run(ctx context.Context, prompt string) (string, error)
|
||||
// Name identifies the backend (e.g. "claude", "codex").
|
||||
Name() string
|
||||
}
|
||||
```
|
||||
|
||||
Two real adapters. The exact invocations below were verified empirically on
|
||||
this machine (claude 2.1.154, codex-cli 0.135.0); both authenticate via the
|
||||
existing CLI login — **no API keys**.
|
||||
|
||||
- **`claudeBackend`** runs:
|
||||
```
|
||||
claude --print --tools "" --no-session-persistence --output-format text
|
||||
```
|
||||
The prompt is delivered on **stdin** (avoids argv limits and shell-escaping;
|
||||
also dodges a quirk where an empty `--tools ""` positional can be mistaken for
|
||||
the prompt). The model's answer is exactly **stdout** (trailing newline
|
||||
trimmed). `--tools ""` disables all tools so it just answers;
|
||||
`--no-session-persistence` avoids writing resumable session files. Do **not**
|
||||
use `--bare` (it forces `ANTHROPIC_API_KEY` and ignores the machine's login).
|
||||
|
||||
- **`codexBackend`** runs:
|
||||
```
|
||||
codex exec --skip-git-repo-check --ignore-user-config --ignore-rules \
|
||||
-s read-only -a never --ephemeral -o <tmpfile> -
|
||||
```
|
||||
The prompt is delivered on **stdin** (the trailing `-` tells codex to read it
|
||||
from stdin). codex's **stdout is not clean** (it includes session preamble),
|
||||
so the adapter writes the final answer to a per-call **temp file** via `-o`,
|
||||
then reads and returns that file's contents. The adapter creates the temp file
|
||||
(`os.CreateTemp`) and removes it on return. The flags matter:
|
||||
`--ignore-user-config --ignore-rules -s read-only` stop codex from executing
|
||||
shell commands driven by local config (observed: it otherwise runs tool calls
|
||||
even for a trivial prompt, adding latency); `-a never` disables approval
|
||||
prompts for headless use; `--ephemeral` skips persisting session files;
|
||||
`--skip-git-repo-check` lets it run anywhere.
|
||||
|
||||
Both use `os/exec` with the `ctx` passed to `exec.CommandContext` so a timeout
|
||||
cancels the child process. Each adapter stores its command name and base args in
|
||||
struct fields so argument construction is unit-testable without spawning a
|
||||
process. The codex adapter's temp-file handling lives inside its `Run` so the
|
||||
`Backend` interface stays uniform (`Run(ctx, prompt) (string, error)`).
|
||||
|
||||
A selector constructs the configured backend:
|
||||
|
||||
```go
|
||||
// NewBackend returns the named backend, or an error for an unknown name.
|
||||
// name "" defaults to "claude".
|
||||
func NewBackend(name string) (Backend, error)
|
||||
```
|
||||
|
||||
**Layer 2 — `Coach` (backend-agnostic capability).**
|
||||
|
||||
```go
|
||||
// Proposal is the coach's structured suggestion for a commitment. It is NOT a
|
||||
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
|
||||
type Proposal struct {
|
||||
NextAction string
|
||||
SuccessCondition string
|
||||
TimeboxSecs int64
|
||||
}
|
||||
|
||||
// Coach turns a free-text intent into a validated Proposal.
|
||||
type Coach interface {
|
||||
Coach(ctx context.Context, intent string) (Proposal, error)
|
||||
}
|
||||
```
|
||||
|
||||
`Service` implements `Coach` over any `Backend`:
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
backend Backend
|
||||
}
|
||||
|
||||
func NewService(b Backend) *Service
|
||||
```
|
||||
|
||||
`Coach` builds a strict prompt, calls `backend.Run`, extracts and parses the
|
||||
JSON, and validates it. The `ai` package imports nothing from the rest of the
|
||||
app (it returns its own `Proposal`, not `domain.Commitment`), so it stays a leaf
|
||||
package with no import cycles.
|
||||
|
||||
### Prompt and JSON contract
|
||||
|
||||
The prompt instructs the model to act as a focus coach and to **return only
|
||||
JSON** of the form:
|
||||
|
||||
```json
|
||||
{
|
||||
"next_action": "Draft the executive summary section",
|
||||
"success_condition": "Summary section has 3 paragraphs covering revenue, risks, outlook",
|
||||
"timebox_minutes": 25
|
||||
}
|
||||
```
|
||||
|
||||
Parsing is tolerant of a chatty CLI:
|
||||
|
||||
- `extractJSON(s string) (string, error)` scans for the first balanced `{...}`
|
||||
object in the output and returns it. This survives leading/trailing prose or
|
||||
code fences.
|
||||
- `parseProposal(jsonStr string) (Proposal, error)` unmarshals into an internal
|
||||
struct with `next_action`, `success_condition`, `timebox_minutes`, then:
|
||||
- trims whitespace; errors if `next_action` or `success_condition` is empty;
|
||||
- errors if `timebox_minutes <= 0`;
|
||||
- converts minutes to `TimeboxSecs` (`minutes * 60`).
|
||||
|
||||
All parse/validation failures return a non-nil error; the caller degrades
|
||||
gracefully (see below). Sentinel errors: `ErrEmptyResponse`, `ErrNoJSON`,
|
||||
`ErrInvalidProposal`.
|
||||
|
||||
Both CLIs also offer native structured-output flags (claude
|
||||
`--output-format json --json-schema`; codex `--output-schema <file>`) that would
|
||||
guarantee shape. We deliberately do **not** use them in M2: they diverge the two
|
||||
adapters (different flags, envelope vs file) and would push schema concerns into
|
||||
the `Backend` layer. Prompt-instructed JSON + tolerant `extractJSON` keeps the
|
||||
`Backend` interface uniform and the parsing in one place. Native schemas remain a
|
||||
clean future robustness upgrade behind the same `Coach` boundary.
|
||||
|
||||
### `session.Controller` — async coach orchestration
|
||||
|
||||
A new method drives the coach using the **exact concurrency pattern** already in
|
||||
`RecordWindow`: mutate state under the mutex, then call `notify()` with the
|
||||
mutex released (`session.go:139-146`).
|
||||
|
||||
```go
|
||||
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
||||
// RequestCoach degrade gracefully.
|
||||
func (c *Controller) SetCoach(coach ai.Coach)
|
||||
|
||||
// RequestCoach starts an async coach call for the given intent. It is a no-op
|
||||
// error path (not a hard failure) unless the runtime state is wrong.
|
||||
func (c *Controller) RequestCoach(intent string) error
|
||||
```
|
||||
|
||||
Behavior of `RequestCoach`:
|
||||
|
||||
1. Lock. If `runtimeState != RuntimePlanning`, unlock and return
|
||||
`ErrNotPlanning` (a real client error — coaching only makes sense in
|
||||
planning).
|
||||
2. If `coach == nil`: set coach state to `status=error`,
|
||||
`err="coach unavailable"`, unlock, `notify()`, return `nil` (graceful — not
|
||||
an HTTP error).
|
||||
3. Otherwise: increment `coachGen`, capture `gen := coachGen`, set
|
||||
`status=pending`, clear prior proposal/error, capture the `coach` reference,
|
||||
unlock, `notify()` (broadcasts the pending state).
|
||||
4. Launch a goroutine:
|
||||
- `ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)`
|
||||
(`coachTimeout = 60 * time.Second` — codex in particular runs tens of
|
||||
seconds even for trivial prompts; 60s gives a real coaching prompt
|
||||
headroom); `defer cancel()`.
|
||||
- Call `coach.Coach(ctx, intent)`.
|
||||
- Lock. **If `gen != c.coachGen` or `runtimeState != RuntimePlanning`,
|
||||
unlock and return** (stale result — a newer request superseded this one, or
|
||||
the user left planning). Discard silently.
|
||||
- On error: `status=error`, `err=<sanitized message>`, `proposal=nil`.
|
||||
- On success: `status=ready`, `proposal=<the Proposal>`, `err=""`.
|
||||
- Unlock, `notify()`.
|
||||
|
||||
The intent string is **not** stored on the controller; it is captured by the
|
||||
goroutine closure only.
|
||||
|
||||
#### Ephemeral state
|
||||
|
||||
The coach state lives on the controller as plain fields and is **never written
|
||||
to the snapshot**:
|
||||
|
||||
```go
|
||||
// on Controller:
|
||||
coach ai.Coach
|
||||
coachStatus string // "idle" | "pending" | "ready" | "error"
|
||||
coachProposal *ai.Proposal
|
||||
coachErr string
|
||||
coachGen int
|
||||
```
|
||||
|
||||
`persistLocked()` is **not** modified — `store.Snapshot` gains no coach fields.
|
||||
Rationale: a proposal is pre-commitment advice; if the daemon restarts during
|
||||
planning, there is nothing to recover, and the user simply re-sharpens.
|
||||
|
||||
Coach state is reset to `idle` (proposal nil, err "") in two places:
|
||||
|
||||
- `EnterPlanning` — entering planning starts with a clean coach.
|
||||
- `StartManualCommitment` and the `enterReview`/`End` paths implicitly leave
|
||||
planning; coach state is reset to `idle` there so a stale `ready` proposal is
|
||||
not projected outside planning. (Concretely: reset in `EnterPlanning` and on
|
||||
any successful leave-planning transition.)
|
||||
|
||||
#### State projection
|
||||
|
||||
`State` gains a coach projection, populated **only while in planning**:
|
||||
|
||||
```go
|
||||
type ProposalView struct {
|
||||
NextAction string `json:"next_action"`
|
||||
SuccessCondition string `json:"success_condition"`
|
||||
TimeboxSecs int64 `json:"timebox_secs"`
|
||||
}
|
||||
|
||||
type CoachView struct {
|
||||
Status string `json:"status"` // idle | pending | ready | error
|
||||
Proposal *ProposalView `json:"proposal,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// added to State:
|
||||
// Coach *CoachView `json:"coach,omitempty"`
|
||||
```
|
||||
|
||||
In `stateLocked()`: if `runtimeState == RuntimePlanning`, attach a `CoachView`
|
||||
with the current status (default `idle`), the proposal if `ready`, and the error
|
||||
if `error`. Outside planning, `Coach` is `nil` and omitted.
|
||||
|
||||
### `web` layer
|
||||
|
||||
One new route:
|
||||
|
||||
```go
|
||||
r.POST("/coach", s.handleCoach)
|
||||
```
|
||||
|
||||
```go
|
||||
type coachRequest struct {
|
||||
Intent string `json:"intent"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCoach(c *gin.Context) {
|
||||
var req coachRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
s.respond(c, s.ctrl.RequestCoach(req.Intent))
|
||||
}
|
||||
```
|
||||
|
||||
`respond` already broadcasts on success and maps errors. `ErrNotPlanning` is a
|
||||
plain (non-`IllegalTransitionError`) error, so it maps to
|
||||
`http.StatusBadRequest` — acceptable, since the UI only shows Sharpen during
|
||||
planning. The pending → ready/error progression reaches the browser entirely
|
||||
over the existing SSE stream; the POST response itself is not relied upon for
|
||||
the proposal.
|
||||
|
||||
### UI (`internal/web/static/index.html`)
|
||||
|
||||
The Planning view gains an intent box and a Sharpen button **above** the three
|
||||
existing inputs:
|
||||
|
||||
```
|
||||
[ Rough intent .......................... ] [ Sharpen ]
|
||||
(coach status line: thinking… / error note)
|
||||
Next action [ ........................ ]
|
||||
Success condition[ ........................ ]
|
||||
Minutes [ 25 ]
|
||||
[ Start commitment ]
|
||||
```
|
||||
|
||||
**Partial-update requirement.** Today `render()` replaces the planning view's
|
||||
`innerHTML` on every SSE message. With a coach, SSE messages now arrive *while
|
||||
the user is typing*, so a full rebuild would wipe their input and focus. The
|
||||
fix:
|
||||
|
||||
- Track the currently rendered runtime state in a module variable
|
||||
(e.g. `renderedState`).
|
||||
- When an SSE message arrives and `rs === 'planning'` **and** the planning view
|
||||
is already mounted, do **not** rebuild. Instead call an
|
||||
`updatePlanningCoach(state.coach)` that only:
|
||||
- updates the coach status line (pending → "thinking…", error → the message,
|
||||
idle/absent → empty);
|
||||
- when status is `ready` and the proposal has not yet been applied for this
|
||||
generation, writes `proposal.next_action`, `proposal.success_condition`, and
|
||||
`Math.round(proposal.timebox_secs / 60)` into the three inputs, then runs the
|
||||
existing `check()` to enable Start. Pre-fill happens once per ready proposal
|
||||
(guard with a flag) so it does not clobber subsequent manual edits on every
|
||||
SSE tick.
|
||||
- Only rebuild the planning structure when transitioning *into* planning from a
|
||||
different state.
|
||||
|
||||
The Sharpen button POSTs `{ intent }` to `/coach` and shows the pending state
|
||||
optimistically; the disabled/enabled logic for Start is unchanged. Other runtime
|
||||
states (`locked`/`active`/`review`) keep their current full-rebuild render.
|
||||
|
||||
## Configuration
|
||||
|
||||
Backend selection is config-driven from day one:
|
||||
|
||||
- Env var `ANTIDRIFT_AI_BACKEND` selects the adapter: `claude` (default) or
|
||||
`codex`. Unknown values are a startup error.
|
||||
- `cmd/antidriftd/main.go` reads the env var, calls `ai.NewBackend(name)`, wraps
|
||||
it in `ai.NewService(backend)`, and calls `ctrl.SetCoach(service)`. If
|
||||
`NewBackend` errors, the daemon logs a warning and runs **without** a coach
|
||||
(manual planning still works) rather than failing to start — graceful
|
||||
degradation extends to misconfiguration.
|
||||
|
||||
## Error Handling and Degradation
|
||||
|
||||
Every failure surfaces as a non-blocking `status=error` in the coach view, never
|
||||
as a broken Planning view:
|
||||
|
||||
| Failure | Result |
|
||||
| ------- | ------ |
|
||||
| No backend wired (`SetCoach` never called / nil) | `RequestCoach` sets `status=error`, "coach unavailable"; returns nil |
|
||||
| CLI binary missing | `backend.Run` errors → goroutine sets `status=error` |
|
||||
| CLI timeout (>60s) | `context` cancels child → error → `status=error` |
|
||||
| Empty / non-JSON output | `extractJSON`/`parseProposal` error → `status=error` |
|
||||
| Missing/empty fields, non-positive timebox | `parseProposal` error → `status=error` |
|
||||
| Request issued outside planning | `RequestCoach` returns `ErrNotPlanning` → HTTP 400 |
|
||||
|
||||
Error messages shown to the UI are sanitized to a short human string; raw CLI
|
||||
stderr is logged server-side, not surfaced to the browser.
|
||||
|
||||
## Package Layout Changes
|
||||
|
||||
| Package | Change |
|
||||
| ------- | ------ |
|
||||
| `ai` (new) | `Backend` interface; `claudeBackend`, `codexBackend`; `NewBackend`; `Coach` interface; `Proposal`; `Service`; prompt builder; `extractJSON`; `parseProposal`; sentinel errors; `fakeBackend` (test) |
|
||||
| `session` | `coach` fields; `SetCoach`; `RequestCoach`; coach reset in `EnterPlanning` and leave-planning paths; `CoachView`/`ProposalView`; `Coach` field on `State`; `stateLocked` projection |
|
||||
| `web` | `POST /coach` route + `handleCoach` + `coachRequest` |
|
||||
| `web/static/index.html` | intent box + Sharpen button; `updatePlanningCoach`; partial-update guard in `render()` |
|
||||
| `cmd/antidriftd` | read `ANTIDRIFT_AI_BACKEND`; build backend + service; `ctrl.SetCoach`; graceful fallback |
|
||||
|
||||
`ai` stays small and single-purpose, consistent with the token-efficiency design
|
||||
constraint.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**`ai` package:**
|
||||
|
||||
- `extractJSON`: bare object, object wrapped in prose, fenced code block, no JSON
|
||||
(error), multiple objects (returns first balanced one).
|
||||
- `parseProposal`: valid; missing `next_action`; empty `success_condition`;
|
||||
`timebox_minutes` of 0 and negative; minutes→secs conversion.
|
||||
- `Service.Coach` against a `fakeBackend` returning canned strings: success,
|
||||
chatty-wrapped success, malformed → error.
|
||||
- `claudeBackend`/`codexBackend`: argument construction is correct and the prompt
|
||||
is routed to stdin (assert on the built `*exec.Cmd` `Args`/`Stdin` fields; do
|
||||
not spawn the real CLI). For codex, assert the `-o <tmpfile>` flag is present
|
||||
and that `Run` would read that path (factor the temp-file path out so it is
|
||||
injectable/observable in the test).
|
||||
- `NewBackend`: returns claude by default, codex by name, error on unknown.
|
||||
|
||||
**`session` package** (with a fake `ai.Coach`):
|
||||
|
||||
- `RequestCoach` in planning, fake returns a proposal: status goes
|
||||
`pending` then `ready`; `State().Coach.Proposal` matches; `onChange` fires
|
||||
twice.
|
||||
- Fake returns an error: status goes `pending` then `error`.
|
||||
- Nil coach: status `error` "coach unavailable"; `RequestCoach` returns nil.
|
||||
- Wrong state (locked/active): `RequestCoach` returns `ErrNotPlanning`; no
|
||||
goroutine, no state change.
|
||||
- Stale generation: two `RequestCoach` calls; the first (slow) fake result is
|
||||
discarded, only the second is projected. (Drive via a fake whose return is
|
||||
gated on a channel so ordering is deterministic.)
|
||||
- Leaving planning discards a pending/ready proposal: `Coach` is nil in `State`
|
||||
once active.
|
||||
- Snapshot has no coach fields (round-trip a snapshot, assert unaffected).
|
||||
|
||||
**`web` package** (with a fake `ai.Coach` wired into a real controller):
|
||||
|
||||
- `POST /coach` in planning returns 200 and the broadcast state shows
|
||||
`status=pending` (or `ready` if the fake is synchronous).
|
||||
- `POST /coach` outside planning returns 400.
|
||||
- `POST /coach` with invalid JSON returns 400.
|
||||
- Coach-unavailable controller: `POST /coach` returns 200, state shows
|
||||
`status=error`.
|
||||
|
||||
All tests use fakes; **no test invokes the real `claude`/`codex` CLI**. Tests
|
||||
must remain race-clean (`go test -race ./...`), consistent with M1.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- `ai` package with both adapters, `Coach`/`Service`, parsing, and tests.
|
||||
- `RequestCoach` async flow with generation-guard and graceful degradation.
|
||||
- `/coach` route and Planning-view Sharpen flow that pre-fills without clobbering
|
||||
user input.
|
||||
- `ANTIDRIFT_AI_BACKEND` wiring in the daemon with graceful fallback.
|
||||
- `go test -race ./...` passes; manual smoke: type an intent, Sharpen, see the
|
||||
three fields populate, edit, Start.
|
||||
- README/roadmap note that M2 is complete (consistent with prior milestones).
|
||||
@@ -1,362 +0,0 @@
|
||||
# M3 — Drift Interceptor — Design
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
## Purpose
|
||||
|
||||
M3 makes drift *visible and interruptive* while a commitment is Active. The
|
||||
daemon watches the focused window (the M1 evidence stream) and, when the user
|
||||
wanders off-task, surfaces a dismissible interrupt in the active view asking
|
||||
them to refocus, justify ("this is on task"), or end the session.
|
||||
|
||||
It uses **cheap local matching first** and the **LLM only for the ambiguous
|
||||
cases**, keeping the slow CLI off the common path. This adds the second live AI
|
||||
role — `JudgeDrift` — at the **cortex** layer: it judges at a decision point the
|
||||
state machine exposes (an active session observing a window), but it never forces
|
||||
a transition. Enforcement (minimizing/blocking) remains deferred to M8; M3's
|
||||
"friction" is UI-only.
|
||||
|
||||
The ambient **Nudge** role is deliberately **out of scope** here; it is the fast
|
||||
follow-on (M3.5).
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope (M3):**
|
||||
|
||||
- Local allowed-context matching ported from `legacy/src/context.rs` into the
|
||||
`evidence` package (window-class + title-substring matching).
|
||||
- The coach (`ai` Coach, from M2) extended to also propose **allowed window
|
||||
classes**; the planning form shows them as an editable field.
|
||||
- A new `DriftJudge` AI role behind a leaf-preserving interface; `Service`
|
||||
implements it via the same CLI backends as M2.
|
||||
- Live drift judgment wired into the `RecordWindow` hot path: debounced
|
||||
(≤ 1 judgment / ~10s) and **cached per window-class**, run in a background
|
||||
goroutine, surfaced over SSE.
|
||||
- An override loop: "this is on task" appends the window-class to the session's
|
||||
allowed-context so it matches locally thereafter.
|
||||
- Active-view drift interrupt UI; `POST /refocus` and `POST /ontask` routes.
|
||||
- Allowed-context persisted in the snapshot (survives restart).
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- The ambient `Nudge` role (M3.5).
|
||||
- Domain/URL and command matching. `AllowedContext` keeps those fields, but the
|
||||
X11 sensor only yields window class + title — no browser URLs or process args —
|
||||
so M3 matches on class + title only.
|
||||
- Any enforcement (window-minimize, blocking): that is M8.
|
||||
- Persisting the drift *verdict*. See "Persistence" — only allowed-context is
|
||||
durable; the verdict recomputes after restart.
|
||||
|
||||
## Architecture
|
||||
|
||||
M3 extends the ports-and-adapters shape established in M1/M2. The `ai` package
|
||||
gains a second role (`DriftJudge`) but stays a **leaf package**; the `evidence`
|
||||
package gains pure matching logic; `session.Controller` orchestrates the
|
||||
debounced async judgment exactly as it orchestrates the M2 coach; the browser
|
||||
renders over the existing SSE stream.
|
||||
|
||||
### The drift pipeline (per window observation, while Active)
|
||||
|
||||
```
|
||||
window observation ──▶ local match against allowed-context?
|
||||
│
|
||||
matched ───┴─── not matched
|
||||
│ │
|
||||
on-task debounce + per-class cache
|
||||
(clear drift) │
|
||||
fresh ──┴── cached
|
||||
│ │
|
||||
JudgeDrift (bg) use cached verdict
|
||||
│
|
||||
verdict on_task / drifting
|
||||
│
|
||||
drift state ──▶ SSE ──▶ active-view interrupt
|
||||
```
|
||||
|
||||
A local match short-circuits to **on-task** with no LLM call. This is
|
||||
authoritative: a window in an allowed class is treated as on-task even if the
|
||||
user is technically idling there. Only **unmatched** windows reach the judge.
|
||||
|
||||
### `evidence` — local matching (ported from Rust)
|
||||
|
||||
New `internal/evidence/context.go`, porting `legacy/src/context.rs`:
|
||||
|
||||
```go
|
||||
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
|
||||
// M3 uses class + title only; domains/commands are matched by the helpers but
|
||||
// have no data source yet.
|
||||
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool
|
||||
```
|
||||
|
||||
with helpers `windowClassAllowed`, `windowTitleAllowed` (and `domainAllowed`,
|
||||
`commandAllowed` ported for completeness/tests, unused on the live path):
|
||||
|
||||
- class: trimmed, casefolded, **exact** match against `WindowClasses`.
|
||||
- title: trimmed, casefolded **substring** match against
|
||||
`WindowTitleSubstrings`.
|
||||
- domain: exact or subdomain (`docs.github.com` matches `github.com`),
|
||||
trailing-dot/whitespace/case normalized.
|
||||
- command: executable basename, casefolded.
|
||||
|
||||
`MatchesAllowed` returns true if class OR title matches. `evidence` may import
|
||||
`domain` (for `AllowedContext`) — `domain` is a pure leaf, so no cycle.
|
||||
|
||||
### `ai` — `DriftJudge`, leaf-preserving
|
||||
|
||||
The M2 review established that `ai` imports nothing from the app. To keep that,
|
||||
`JudgeDrift` takes **primitives**, not `domain`/`evidence` types — the controller
|
||||
formats the commitment and window into strings before calling.
|
||||
|
||||
```go
|
||||
// Verdict is the drift judge's call on a single window.
|
||||
type Verdict struct {
|
||||
OnTask bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// DriftJudge decides whether the current window is on-task for a commitment.
|
||||
type DriftJudge interface {
|
||||
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
||||
}
|
||||
```
|
||||
|
||||
`Service` (from M2) also implements `DriftJudge`:
|
||||
|
||||
```go
|
||||
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
||||
```
|
||||
|
||||
It builds a strict-JSON prompt, calls `backend.Run`, and parses:
|
||||
|
||||
```json
|
||||
{"on_task": false, "reason": "Reddit is unrelated to drafting the report"}
|
||||
```
|
||||
|
||||
- `buildDriftPrompt(commitment, class, title)` — gives the model the commitment
|
||||
description and the current window, asks for ONLY the JSON object above.
|
||||
- `parseVerdict(s string) (Verdict, error)` — reuses `extractJSON`; unmarshals
|
||||
`{on_task bool, reason string}`; trims `reason`. A missing/empty body or
|
||||
unparseable output returns an error (sentinel `ErrInvalidVerdict`); the caller
|
||||
degrades by leaving drift state unchanged.
|
||||
|
||||
### Coach extension (allowed window classes)
|
||||
|
||||
`ai.Proposal` gains a field; the coach prompt asks for it:
|
||||
|
||||
```go
|
||||
type Proposal struct {
|
||||
NextAction string
|
||||
SuccessCondition string
|
||||
TimeboxSecs int64
|
||||
AllowedWindowClasses []string // NEW
|
||||
}
|
||||
```
|
||||
|
||||
Prompt JSON shape extends to:
|
||||
`{"next_action":..., "success_condition":..., "timebox_minutes":..., "allowed_window_classes":["code","firefox"]}`.
|
||||
`parseProposal` reads the new array (optional — absent/empty is valid; the user
|
||||
can fill it in). `ProposalView` (session) gains `allowed_window_classes`.
|
||||
|
||||
### `session.Controller` — orchestration
|
||||
|
||||
New ephemeral + durable state on the controller:
|
||||
|
||||
```go
|
||||
// durable (persisted): the active session's allowed classes, mutable via override
|
||||
allowedClasses []string
|
||||
|
||||
// ephemeral drift machinery
|
||||
judge ai.DriftJudge
|
||||
driftStatus string // "idle" | "pending" | "ontask" | "drifting"
|
||||
driftReason string
|
||||
driftGen int
|
||||
lastJudgedAt time.Time
|
||||
judgedClasses map[string]ai.Verdict // per-class cache for this session
|
||||
```
|
||||
|
||||
**Injection:** `SetDriftJudge(ai.DriftJudge)` (mirrors `SetCoach`). Nil judge ⇒
|
||||
drift stays idle; local matching still runs (a matched window shows on-task).
|
||||
|
||||
**Hot-path hook** in `RecordWindow` (while holding the lock, after `applyEvent`,
|
||||
only when Active):
|
||||
|
||||
1. Build `domain.AllowedContext{WindowClasses: c.allowedClasses}` and test
|
||||
`MatchesAllowed(ac, class, title)`. On match ⇒ set `driftStatus=ontask`,
|
||||
clear reason. Done (no LLM). (Only `WindowClasses` is populated in M3, since
|
||||
the coach proposes classes; title substrings are matched by the helper but
|
||||
left empty.)
|
||||
2. Else consult per-class cache: if `judgedClasses[class]` exists ⇒ apply it
|
||||
(`ontask`/`drifting` + reason). Done.
|
||||
3. Else **debounce**: if `now.Sub(lastJudgedAt) < driftDebounce` (10s) ⇒ leave
|
||||
current state. Done.
|
||||
4. Else launch judgment: bump `driftGen`, capture `gen`, set
|
||||
`lastJudgedAt=now`, `driftStatus=pending`, capture `judge` + commitment text
|
||||
+ class/title, then (after unlocking, per the M2 pattern) run the judge in a
|
||||
goroutine with a `driftTimeout` (30s) context.
|
||||
|
||||
**Goroutine completion** (re-acquire lock): discard if `gen != driftGen` or no
|
||||
longer Active (stale — commitment ended/changed). Else cache
|
||||
`judgedClasses[class]=verdict`; if `class` is still the current window's class,
|
||||
set `driftStatus` to `ontask`/`drifting` + reason. Unlock, `notify()`.
|
||||
|
||||
All `notify()` calls fire with the mutex released — identical discipline to M2's
|
||||
`RequestCoach` and the existing focus path.
|
||||
|
||||
**Override / dismiss:**
|
||||
|
||||
```go
|
||||
// OnTask appends the current window class to the session allowed-context, clears
|
||||
// drift, and persists. The class now matches locally and is never re-judged.
|
||||
func (c *Controller) OnTask() error
|
||||
|
||||
// Refocus clears the current drift verdict without changing allowed-context.
|
||||
// The same off-task class may be judged again later.
|
||||
func (c *Controller) Refocus() error
|
||||
```
|
||||
|
||||
Both return `ErrNotActive` outside the Active state. `OnTask` appends to
|
||||
`allowedClasses`, drops any cached drifting verdict for that class, sets
|
||||
`driftStatus=ontask`, and persists the snapshot.
|
||||
|
||||
**Commitment start:** `StartManualCommitment` gains an `allowedClasses []string`
|
||||
parameter, stored on the controller and persisted. Drift caches/state reset when
|
||||
a session starts and when it ends (`End`).
|
||||
|
||||
### State projection
|
||||
|
||||
`State` gains a drift view, projected **only while Active**:
|
||||
|
||||
```go
|
||||
type DriftView struct {
|
||||
Status string `json:"status"` // idle | pending | ontask | drifting
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
// added to State:
|
||||
// Drift *DriftView `json:"drift,omitempty"`
|
||||
```
|
||||
|
||||
`CommitmentView` is unchanged; `ProposalView` gains
|
||||
`AllowedWindowClasses []string json:"allowed_window_classes,omitempty"`.
|
||||
|
||||
### Persistence
|
||||
|
||||
`store.Snapshot` gains `AllowedWindowClasses []string`. `persistLocked` writes
|
||||
the controller's `allowedClasses`; `New` restores them when a live Active session
|
||||
is rebuilt. **The drift verdict is NOT persisted** — on restart it recomputes
|
||||
from the first post-restart window observation (≤ one debounce window). This
|
||||
avoids showing a stale "drifting" interrupt for a window the user has already
|
||||
navigated away from, at the cost of a brief idle state after restart. This is a
|
||||
deliberate refinement of "persist session state across restart".
|
||||
|
||||
### `web` layer
|
||||
|
||||
- `commitmentRequest` gains `AllowedWindowClasses []string json:"allowed_window_classes"`;
|
||||
`handleCommitment` passes them to `StartManualCommitment`.
|
||||
- `POST /refocus` → `ctrl.Refocus()`; `POST /ontask` → `ctrl.OnTask()`. Both via
|
||||
the existing `respond` helper (so `ErrNotActive` maps to 400, success
|
||||
broadcasts).
|
||||
|
||||
### UI (`internal/web/static/index.html`)
|
||||
|
||||
**Planning view:** add an "Allowed apps" input (comma-separated window classes),
|
||||
pre-filled from `coach.proposal.allowed_window_classes` when a proposal lands
|
||||
(same one-time, non-clobbering pre-fill as M2). The Start commitment POST
|
||||
includes the parsed list.
|
||||
|
||||
**Active view:** when `state.drift.status === 'drifting'`, render an interrupt
|
||||
block above/around the timer:
|
||||
|
||||
```
|
||||
⚠ Possible drift
|
||||
<reason>
|
||||
[ Back to task ] [ This is on task ] [ End session ]
|
||||
```
|
||||
|
||||
- `Back to task` → `POST /refocus`
|
||||
- `This is on task` → `POST /ontask`
|
||||
- `End session` → the existing `POST /complete` (Active → Review), same as the
|
||||
active view's current Complete button
|
||||
|
||||
`status === 'pending'` may show a subtle "checking…" hint; `ontask`/`idle` show
|
||||
nothing. The active view already rebuilds on SSE ticks and runs a countdown
|
||||
timer; the drift block must integrate without resetting the timer — apply the
|
||||
same partial-update care used for the planning coach (update the drift region
|
||||
without tearing down the countdown). The interrupt is **non-modal** (it cannot
|
||||
lock the user out — enforcement is M8).
|
||||
|
||||
## Configuration
|
||||
|
||||
No new configuration. The drift judge reuses the M2 backend selected by
|
||||
`ANTIDRIFT_AI_BACKEND`; the daemon wires the single `Service` into both
|
||||
`SetCoach` and `SetDriftJudge`. Debounce (10s) and timeout (30s) are constants.
|
||||
|
||||
## Error Handling and Degradation
|
||||
|
||||
| Condition | Result |
|
||||
| --------- | ------ |
|
||||
| Nil judge (unwired/misconfig) | Local matching still runs; unmatched windows leave drift `idle` — never blocks |
|
||||
| CLI failure / timeout / unparseable verdict | Judgment discarded; drift state unchanged (no false "drifting"); logged server-side |
|
||||
| Judge slow, user changes window | Per-class cache + generation guard; stale results discarded |
|
||||
| `/refocus` or `/ontask` outside Active | `ErrNotActive` → HTTP 400 |
|
||||
|
||||
Drift judgment failures **never** fabricate a drift verdict; the safe default is
|
||||
"not drifting".
|
||||
|
||||
## Package Layout Changes
|
||||
|
||||
| Package | Change |
|
||||
| ------- | ------ |
|
||||
| `evidence` | New `context.go` (matching helpers + `MatchesAllowed`) + tests ported from `legacy/src/context.rs` |
|
||||
| `ai` | `Verdict`, `DriftJudge`, `Service.JudgeDrift`, `buildDriftPrompt`, `parseVerdict`, `ErrInvalidVerdict`; `Proposal.AllowedWindowClasses` + coach prompt/parse updates |
|
||||
| `session` | `allowedClasses` (persisted), drift machinery (judge, status, reason, gen, debounce, per-class cache); `SetDriftJudge`; `RecordWindow` hook; `OnTask`/`Refocus`; `StartManualCommitment` allowed-classes param; `DriftView` + `State.Drift`; `ProposalView.AllowedWindowClasses`; snapshot field |
|
||||
| `store` | `Snapshot.AllowedWindowClasses` |
|
||||
| `web` | `commitmentRequest.AllowedWindowClasses`; `POST /refocus`, `POST /ontask` |
|
||||
| `web/static/index.html` | planning "allowed apps" field; active-view drift interrupt; partial-update care |
|
||||
| `cmd/antidriftd` | `ctrl.SetDriftJudge(service)` alongside `SetCoach` |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**`evidence`:** port the `context.rs` test table (class exact/casefold, title
|
||||
substring, domain exact/subdomain/normalization, command basename) plus
|
||||
`MatchesAllowed` (class-only match, title-only match, neither).
|
||||
|
||||
**`ai`:** `parseVerdict` (valid on_task true/false, chatty-wrapped, missing
|
||||
fields → error); `Service.JudgeDrift` over a `fakeBackend`; `parseProposal` now
|
||||
reads `allowed_window_classes` (present, absent, empty); arg/prompt building does
|
||||
not regress. No real CLI.
|
||||
|
||||
**`session`** (with a fake `DriftJudge`):
|
||||
|
||||
- Local match ⇒ `ontask`, judge never called.
|
||||
- Unmatched ⇒ `pending` then `drifting`/`ontask` per the fake's verdict.
|
||||
- Per-class cache: second observation of a judged class does not call the judge
|
||||
again.
|
||||
- Debounce: rapid unmatched observations within 10s trigger at most one judge
|
||||
call (drive `clock` via the existing `SetClock`).
|
||||
- Stale generation: slow judge result discarded after the session ends / a new
|
||||
one starts (gate the fake on a channel, as in the M2 coach test).
|
||||
- `OnTask` appends the class, clears drift, and a subsequent observation of that
|
||||
class matches locally (no judge call); persisted across reload.
|
||||
- `Refocus` clears drift without mutating allowed-context.
|
||||
- Restart restores `allowedClasses` from the snapshot; drift starts `idle`.
|
||||
- Nil judge: unmatched window leaves drift `idle`, no panic.
|
||||
|
||||
**`web`:** `/refocus` and `/ontask` happy paths + outside-Active 400; commitment
|
||||
request carries allowed classes into the controller.
|
||||
|
||||
All tests use fakes; **no test spawns a real CLI**. `go test -race ./...` stays
|
||||
clean.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- `evidence` matching ported with tests.
|
||||
- `ai` `DriftJudge` + coach allowed-classes extension, with tests.
|
||||
- Controller drift pipeline (local-first, debounced, cached, async) with
|
||||
override/dismiss and persistence, race-clean.
|
||||
- `/refocus`, `/ontask` routes; planning allowed-apps field; active-view drift
|
||||
interrupt that doesn't disrupt the timer.
|
||||
- Daemon wires `SetDriftJudge`.
|
||||
- `go test -race ./...` and `go vet ./...` pass; manual smoke: start a session
|
||||
with an allowed class, switch to an unrelated app, see the interrupt, exercise
|
||||
both buttons.
|
||||
- README/roadmap note M3 complete.
|
||||
@@ -1,262 +0,0 @@
|
||||
# M3.5 — Semantic Nudge — Design
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
## Purpose
|
||||
|
||||
M3 makes drift visible when the user switches to the *wrong application* — a
|
||||
cheap local match owns the common case and the LLM judges the ambiguous ones,
|
||||
producing an interruptive banner. But that machinery is structurally blind to a
|
||||
second failure mode: the user sits in an **allowed application the whole time**
|
||||
and still drifts — coding the wrong project in the editor, reading tangential
|
||||
docs in an allowed browser. Local match is authoritative for on-task and caches
|
||||
per window-class, so once a class is allowed the user is never re-judged while
|
||||
in it. They can rabbit-hole for an hour and be called perfectly on-task.
|
||||
|
||||
M3.5 adds the third and final advisor role, **`Nudge`**, to close that gap. It
|
||||
catches *"right app, wrong work"* — **semantic** drift within allowed apps —
|
||||
with a soft, ambient, periodic check-in. It is the deliberate, narrow follow-on
|
||||
that completes the original M3 roadmap entry ("drift interceptor + ambient
|
||||
nudge").
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope (M3.5):**
|
||||
|
||||
- A new `Nudge` AI role behind a leaf-preserving interface (`ai.Nudger`);
|
||||
`Service` implements it via the same CLI backends as the coach and drift
|
||||
judge. Signature takes primitives only: `Nudge(ctx, commitment string,
|
||||
recentTitles []string) (string, error)`, returning an advisory sentence, or
|
||||
`""` when the trajectory is still on-task.
|
||||
- A small in-memory ring of the **last 10 distinct window titles** seen during
|
||||
the active session — the trajectory signal the nudge judges against.
|
||||
- The nudge wired into `evaluateDriftLocked`'s **local-match on-task branch
|
||||
only**: it runs precisely where the drift judge stays silent, so the two are
|
||||
mutually exclusive per observation and never overlap.
|
||||
- Debounced to at most one nudge per `nudgeDebounce` (5 min); run in a
|
||||
background goroutine; gated to Active sessions with ≥ 2 titles of history.
|
||||
- Surfaced over SSE as a new `DriftView.Nudge` field; the active view renders a
|
||||
visually distinct **soft "Heads up" tier** on the existing drift banner —
|
||||
dismiss-only, no action buttons.
|
||||
- Graceful degradation: a nil nudger leaves the system silent; everything else
|
||||
works unchanged.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- Any enforcement: the nudge informs and never forces — same as the drift
|
||||
interceptor. Enforcement remains M8.
|
||||
- Persisting the nudge message (ephemeral, like the drift verdict — recomputes
|
||||
after restart).
|
||||
- A server-side dismiss route: dismissal is client-side, keyed on the message
|
||||
text, and auto-clears when the trajectory recovers or the session changes.
|
||||
- Re-running the nudge on the *cached-on-task* or *judged-on-task* paths. The
|
||||
nudge is gated strictly to the local-match-authoritative on-task path. (Once
|
||||
"This is on task" appends a class to the allowed list, it becomes a local
|
||||
match thereafter, so the realistic "I'm in my app" case is covered.)
|
||||
- Changing the hard local rules. "Outlook during a focus session = violation"
|
||||
stays exactly where it is in the interceptor; the nudge is purely the soft
|
||||
semantic layer above it.
|
||||
|
||||
## Architecture
|
||||
|
||||
M3.5 extends the ports-and-adapters shape of M1/M2/M3 without adding any new
|
||||
infrastructure. The `ai` package gains a third role (`Nudger`) but stays a
|
||||
**leaf package**: like `Coach` and `DriftJudge`, `Nudger` takes primitive
|
||||
strings, not `domain`/`evidence` types. `session.Controller` orchestrates the
|
||||
debounced async nudge with the *exact same discipline* as the M3 drift judge —
|
||||
debounce, on-task-stretch epoch guard, goroutine launched after releasing the
|
||||
mutex, `notify()` only with the mutex released, never fabricate on error.
|
||||
|
||||
### The `ai.Nudger` role
|
||||
|
||||
```go
|
||||
// Nudger judges whether recent activity within an allowed app still serves the
|
||||
// commitment. It takes primitives, not domain/evidence types, so ai stays a
|
||||
// leaf package. The returned string is a one-sentence advisory, or "" when the
|
||||
// trajectory is still on-task.
|
||||
type Nudger interface {
|
||||
Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
`Service.Nudge` runs `buildNudgePrompt(commitment, recentTitles)` on the
|
||||
backend and parses the result. The prompt asks for exactly:
|
||||
|
||||
```json
|
||||
{"on_track": <true or false>, "message": "<one short sentence>"}
|
||||
```
|
||||
|
||||
Parsing mirrors `parseVerdict` and reuses the shared `extractJSON` /
|
||||
`ErrEmptyResponse` / `ErrNoJSON` helpers:
|
||||
|
||||
- `on_track: true` → return `""` (nothing to surface).
|
||||
- `on_track: false` with a non-empty `message` → return the trimmed message.
|
||||
- `on_track: false` with **no** message → treat as on-track (return `""`). A
|
||||
concern with no words is unusable and would only add noise, so the parser
|
||||
swallows it rather than erroring. (This differs from `parseVerdict`, which
|
||||
rejects a reasonless drift as `ErrInvalidVerdict` because the drift banner is
|
||||
interruptive; the nudge is ambient and silent-by-default, so the safe
|
||||
degenerate is silence, not an error.)
|
||||
- Empty / no-JSON / malformed output → the corresponding error, surfaced to the
|
||||
caller, which logs and stays silent.
|
||||
|
||||
### Controller state
|
||||
|
||||
New fields on `Controller` (all reset per session in `resetDriftLocked`):
|
||||
|
||||
- `nudge ai.Nudger` — the injected judge; nil disables nudging.
|
||||
- `recentTitles []string` — ring of the last 10 distinct titles this session.
|
||||
- `nudgeMessage string` — the current soft advisory ("" = none).
|
||||
- `lastNudgedAt time.Time` — debounce timestamp.
|
||||
|
||||
A soft nudge advisory belongs to one continuous on-task stretch in an allowed
|
||||
app, so the nudge is guarded by an on-task-stretch epoch (`nudgeEpoch`) rather
|
||||
than `driftGen` (which bumps on every drift-judgment launch). `nudgeEpoch`
|
||||
advances on session reset (`resetDriftLocked`) and whenever an observation is
|
||||
not a local on-task match — i.e. the stretch ended. A nudge captures the epoch
|
||||
at launch and applies its result only if the epoch is unchanged, so a nudge
|
||||
whose stretch has since ended (a drift episode or a session change) is discarded
|
||||
instead of surfacing stale. Leaving the allowed app additionally clears any
|
||||
already-set advisory. The net effect: the advisory auto-clears when the
|
||||
trajectory changes or recovers, and a stale "Heads up" never resurfaces after a
|
||||
drift episode.
|
||||
|
||||
New constants alongside the drift ones:
|
||||
|
||||
```go
|
||||
nudgeDebounce = 5 * time.Minute
|
||||
nudgeTimeout = 30 * time.Second
|
||||
```
|
||||
|
||||
### Data flow
|
||||
|
||||
`RecordWindow` is unchanged in shape: it still captures a single `launch func()`
|
||||
from `evaluateDriftLocked` and runs it via `go launch()` after unlocking. The
|
||||
drift judge and the nudge are **mutually exclusive** per observation —
|
||||
matched → maybe-nudge; unmatched → maybe-judge — so one closure return covers
|
||||
both.
|
||||
|
||||
1. **Recent-titles ring.** While Active, `RecordWindow` appends the observed
|
||||
title to `recentTitles` when it is non-empty and differs from the most recent
|
||||
entry, capping the slice at 10 (drop oldest). This happens before drift
|
||||
evaluation so the latest title is in view.
|
||||
2. **Nudge branch.** In `evaluateDriftLocked` step 1, when `MatchesAllowed`
|
||||
returns true, the controller sets `driftOnTask` as today and then evaluates
|
||||
nudge eligibility:
|
||||
- `c.nudge != nil`, runtime is Active, `len(c.recentTitles) >= 2`, and
|
||||
`lastNudgedAt` is zero or `now.Sub(lastNudgedAt) >= nudgeDebounce`.
|
||||
- If eligible: stamp `lastNudgedAt = now`, capture `epoch := c.nudgeEpoch`,
|
||||
the commitment string (same `NextAction — SuccessCondition` form as the
|
||||
drift judge), and a **copy** of `recentTitles`; return the nudge closure.
|
||||
- If not eligible: return `nil` (unchanged behavior).
|
||||
3. **Nudge closure** (runs in the goroutine):
|
||||
- Calls `nudge.Nudge(ctx, commitment, titles)` under a `nudgeTimeout`
|
||||
context.
|
||||
- Re-acquires the lock; if `epoch != c.nudgeEpoch || c.runtimeState !=
|
||||
RuntimeActive` → stale, return.
|
||||
- On error → log, leave `nudgeMessage` unchanged (no fabrication), unlock,
|
||||
return. (`lastNudgedAt` stays set, so a failed call does not immediately
|
||||
retry.)
|
||||
- On success → set `c.nudgeMessage = msg` (which may be `""`, clearing a
|
||||
prior advisory once the trajectory recovers); unlock; `notify()`.
|
||||
|
||||
### Surfacing
|
||||
|
||||
`DriftView` gains one field:
|
||||
|
||||
```go
|
||||
type DriftView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Nudge string `json:"nudge,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
The nudge is a **separate axis** from `Status`: it is populated precisely when
|
||||
`Status == "ontask"`, so it cannot be folded into the status enum.
|
||||
`stateLocked` sets `Nudge: c.nudgeMessage` whenever it builds the `DriftView`.
|
||||
|
||||
In `index.html`, `updateActiveDrift(drift)` renders, in priority order:
|
||||
|
||||
- `drift.status === "drifting"` → the existing hard banner (Back to task / This
|
||||
is on task / End session). Unchanged.
|
||||
- else if `drift.nudge` is non-empty → a **soft "Heads up" tier**: muted
|
||||
styling clearly lower-stakes than the hard banner, the message text, and a
|
||||
single **Dismiss** control. No Refocus/OnTask/Complete buttons.
|
||||
- else → clear the region.
|
||||
|
||||
**Client-side dismiss.** Dismiss hides the soft tier and remembers the dismissed
|
||||
message text in a module-level variable; the renderer skips re-showing that
|
||||
exact text. When the nudge message changes (a new concern) or clears and later
|
||||
returns, it shows again. No server round-trip, no new route.
|
||||
|
||||
### Wiring
|
||||
|
||||
`cmd/antidriftd/main.go`: the single `ai.Service` already satisfies `Coach` and
|
||||
`DriftJudge`; add `ctrl.SetNudge(svc)` and update the startup log line to note
|
||||
the third role.
|
||||
|
||||
## Persistence
|
||||
|
||||
Nothing new is persisted. `recentTitles`, `nudgeMessage`, and `lastNudgedAt` are
|
||||
all in-memory session state, cleared by `resetDriftLocked` on session start and
|
||||
on the Active-restore path (the same place that already resets drift state to
|
||||
avoid stale interrupts after a restart).
|
||||
|
||||
## Error handling
|
||||
|
||||
- Backend/parse failure → logged, `nudgeMessage` untouched, system silent. The
|
||||
nudge never fabricates a concern, mirroring the drift judge's "never fabricate
|
||||
drift" rule.
|
||||
- A reasonless concern from the model degrades to silence (see parsing).
|
||||
- Stale results (the on-task stretch ended via a drift episode, or the session
|
||||
ended/restarted mid-call) are discarded by the `nudgeEpoch` guard.
|
||||
|
||||
## Testing
|
||||
|
||||
**`internal/ai/nudge_test.go`** (stdlib `testing`, table-driven like
|
||||
`verdict_test.go`):
|
||||
|
||||
- `on_track: true` → `""`, nil error.
|
||||
- `on_track: false` + message → trimmed message.
|
||||
- `on_track: false` + empty message → `""` (tolerant degrade).
|
||||
- empty output → `ErrEmptyResponse`; no-brace output → `ErrNoJSON`; malformed
|
||||
JSON → wrapped parse error.
|
||||
- JSON embedded in surrounding prose → extracted (exercises `extractJSON`
|
||||
reuse).
|
||||
|
||||
**`internal/session/session_test.go`** (extend, reuse the `fakeJudge`-style
|
||||
harness with a `fakeNudger`: configurable message/err, an optional gate channel,
|
||||
an atomic call counter):
|
||||
|
||||
- Nudge fires on the local-match on-task path once history ≥ 2 and debounce has
|
||||
elapsed; sets `DriftView.Nudge`.
|
||||
- Nudge does **not** fire on the unmatched (drift-judge) path — verify the
|
||||
nudger sees zero calls when the window is off-app.
|
||||
- Debounce limits nudges to one per `nudgeDebounce` (drive with `SetClock`).
|
||||
- A nudger error leaves `nudgeMessage` empty (no fabrication) and does not crash.
|
||||
- An `on_track` result clears a previously set `nudgeMessage`.
|
||||
- A stale nudge (session ended before the call returns) is discarded — message
|
||||
not applied.
|
||||
- A nil nudger leaves the on-task path silent (no nudge, no panic).
|
||||
- `recentTitles` records distinct titles and caps at 10.
|
||||
|
||||
**`internal/web/web_test.go`**: assert the state JSON carries the `nudge` field
|
||||
when set (extend an existing active-state test rather than adding a route test —
|
||||
there is no new route).
|
||||
|
||||
All tests pass under `go test -race ./...`; `go vet ./...` clean.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/ai/nudge.go` | new: `Nudger` interface, `Service.Nudge`, `buildNudgePrompt`, `parseNudge` |
|
||||
| `internal/ai/nudge_test.go` | new: parse/role tests |
|
||||
| `internal/session/session.go` | nudge fields, constants, `recentTitles` ring in `RecordWindow`, nudge branch + closure in `evaluateDriftLocked`, `SetNudge`, `resetDriftLocked` additions, `DriftView.Nudge`, `stateLocked` wiring |
|
||||
| `internal/session/session_test.go` | `fakeNudger` + nudge tests |
|
||||
| `internal/web/web.go` | (only if a struct/tag touch is needed; likely none — `DriftView` is in `session`) |
|
||||
| `internal/web/web_test.go` | assert `nudge` in state JSON |
|
||||
| `internal/web/static/index.html` | soft "Heads up" tier in `updateActiveDrift` + CSS; client-side dismiss |
|
||||
| `cmd/antidriftd/main.go` | `ctrl.SetNudge(svc)` + log line |
|
||||
| `README.md` | M3.5 paragraph in Status |
|
||||
@@ -1,134 +0,0 @@
|
||||
# M4 — "Look good" Design
|
||||
|
||||
**Goal:** A real design pass on the web UI: a cockpit-style, state-aware HUD that
|
||||
reads at a glance, with CSS/JS split out of the inline HTML for maintainability,
|
||||
and a polished review recap. No behavior changes.
|
||||
|
||||
**Status:** Design approved 2026-05-31. Supersedes the utilitarian inline UI
|
||||
shipped through M3.5.
|
||||
|
||||
---
|
||||
|
||||
## 1. Direction & Visual System
|
||||
|
||||
The UI is an **instrument panel you glance at** — a cockpit, not a document.
|
||||
Dark, near-black cool-neutral base. State is carried by a **single state-driven
|
||||
accent**: a CSS custom property `--accent` switched by a `data-state` attribute
|
||||
on the `<main>` element. The accent colors the status band's top border and the
|
||||
state pill, so the frame itself communicates where you are without reading text.
|
||||
|
||||
### State → accent mapping
|
||||
|
||||
| State | `data-state` | Accent |
|
||||
|--------------------|--------------|--------------------|
|
||||
| locked | `locked` | dim gray |
|
||||
| planning | `planning` | blue |
|
||||
| active · on-task | `active` | calm green / cyan |
|
||||
| active · nudge | `nudge` | amber |
|
||||
| active · drifting | `drift` | red |
|
||||
| review | `review` | violet-neutral |
|
||||
|
||||
`data-state` is derived in the client render from `runtime_state` plus, when
|
||||
active, the drift sub-status (`drifting` → `drift`; a present `nudge` → `nudge`;
|
||||
otherwise `active`). This mirrors the precedence already used by the status-file
|
||||
renderer (drift outranks nudge).
|
||||
|
||||
### Design tokens (CSS custom properties)
|
||||
|
||||
- Surfaces / text: `--bg`, `--panel`, `--line`, `--ink`, `--ink-dim`
|
||||
- State accent: `--accent` (the only variable that changes with `data-state`)
|
||||
- Fixed semantic colors: `--ok`, `--warn`, `--danger`
|
||||
|
||||
### Typography
|
||||
|
||||
- Timer: heavy weight, `font-variant-numeric: tabular-nums`.
|
||||
- Evidence times: `ui-monospace` so the bucket columns align.
|
||||
- Band headers / pills: small, uppercase, letter-spaced (keeps the existing pill
|
||||
idiom from the current UI).
|
||||
- Prose: `system-ui`.
|
||||
|
||||
## 2. Layout — Stacked HUD Bands
|
||||
|
||||
Every state composes the same **band primitive**: a row with a top divider and
|
||||
consistent horizontal/vertical padding. Stacking bands produces the layered HUD
|
||||
look. The active session follows the approved sketch:
|
||||
|
||||
```
|
||||
ACTIVE · on task · 7 switches ← status band (accent border-top + pill)
|
||||
24:18 write the spec section ← timer band
|
||||
done when: draft saved ← task band
|
||||
now code·spec ● | code 18:02 … ← evidence band
|
||||
[ Complete ] ← action band
|
||||
```
|
||||
|
||||
Drift and nudge are **not** a separate floating box. When the session drifts or
|
||||
is nudged, the **status band itself** changes copy and `data-state` flips, so the
|
||||
whole frame goes amber/red. The same controls render inside that band:
|
||||
|
||||
- Drift: `Back to task` (`/refocus`), `This is on task` (`/ontask`),
|
||||
`End session` (`/complete`).
|
||||
- Nudge: `Dismiss` (client-only, current behavior).
|
||||
- Pending: a quiet "checking focus…" line.
|
||||
|
||||
## 3. Per-State Treatment
|
||||
|
||||
- **Locked:** one dim band, large `Start planning` button (`/planning`).
|
||||
- **Planning:** an intent + `Sharpen` band, then field bands — Next action,
|
||||
Success condition, Minutes, Allowed apps — with the blue accent. All existing
|
||||
input ids (`#intent`, `#na`, `#sc`, `#mins`, `#apps`, `#start`,
|
||||
`#coachStatus`) and the coach pre-fill behavior are untouched.
|
||||
- **Active:** the HUD described in §2.
|
||||
- **Review (polished, presentational only):** summary bands built from data the
|
||||
state already carries — `next_action`, `success_condition`, the context-switch
|
||||
count, and the per-window bucket recap (reusing the existing `evidence`
|
||||
fields). **No new backend data** is introduced; richer session reflection is
|
||||
M7's job. The `End` button (`/end`) remains.
|
||||
|
||||
## 4. Structure
|
||||
|
||||
Split the single inline file into three files under `internal/web/static/`:
|
||||
|
||||
- `index.html` — markup shell only (`<head>` links the stylesheet and script).
|
||||
- `app.css` — the full visual system (tokens, bands, per-state rules).
|
||||
- `app.js` — the render logic, **moved verbatim**: same `render()` function,
|
||||
same partial-update paths (`updateActiveDrift`, `updatePlanningCoach`), same
|
||||
element ids, same `EventSource('/events')` and POST endpoints. The only
|
||||
additions are the band markup in the template strings and setting
|
||||
`main.dataset.state` per render.
|
||||
|
||||
`web.go` currently serves only `/` via `c.FileFromFS`. Add routes so the two new
|
||||
assets are served from the embedded `staticFS`:
|
||||
|
||||
- `GET /app.css` → `static/app.css`
|
||||
- `GET /app.js` → `static/app.js`
|
||||
|
||||
No new Go dependencies, no JavaScript build step, no framework. The embedded
|
||||
static directory and the conciseness/token-efficiency ethos of the Go rewrite
|
||||
are preserved.
|
||||
|
||||
## 5. Behavior & Data Flow — Unchanged
|
||||
|
||||
Same SSE stream, same partial-update `render()` logic, same element ids, same
|
||||
POST endpoints, same server-authoritative expiry timer. The redesign is markup +
|
||||
CSS + asset routing only. This is precisely what keeps the existing
|
||||
`web_test.go` (endpoint and state-JSON assertions, markup-agnostic) green.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Existing `web_test.go` stays green.** It asserts on endpoint status codes and
|
||||
the state JSON, not on HTML markup, so the visual rework does not touch it.
|
||||
- **New Go test:** `GET /app.css` and `GET /app.js` each return `200` with the
|
||||
correct `Content-Type` (`text/css`, `text/javascript` / `application/javascript`).
|
||||
Asserted against the router via `httptest`, stdlib `testing` only.
|
||||
- **Manual visual checklist** across the six `data-state` values: locked,
|
||||
planning, active (on-task), active (nudge), active (drift), review. There is no
|
||||
JavaScript test harness in this Go project; the rendering is presentational and
|
||||
verified by eye, consistent with the existing approach.
|
||||
|
||||
## 7. Out of Scope
|
||||
|
||||
- Micro-interactions / motion (timer easing, accent transitions, panel slide-in)
|
||||
— explicitly excluded for M4; can be a later pass.
|
||||
- Any new backend data or fields on the state payload.
|
||||
- M7 reflection content (real session summary, time-on-task analytics). The M4
|
||||
review screen is presentational recap of already-available data only.
|
||||
@@ -1,148 +0,0 @@
|
||||
# M5 — Tasks Port Design
|
||||
|
||||
**Goal:** Add the `tasks.Provider` port — answering "what should I be doing?" —
|
||||
with an Amazing Marvin adapter that shells out to `am --json`. Today's tasks
|
||||
surface on the planning screen; clicking one seeds the intent field, which flows
|
||||
into the existing AI coach. Read-only, no writeback, graceful degradation.
|
||||
|
||||
**Status:** Design approved 2026-05-31. Implements the deferred `tasks` port
|
||||
named in `2026-05-31-go-focus-os-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Direction
|
||||
|
||||
The Tasks port is the third real port, after Activity (`evidence`) and Advisor
|
||||
(`ai`). It follows the pattern M1 established: a small leaf-package interface, a
|
||||
single CLI adapter, and a fake for tests. It returns primitives only, so it
|
||||
imports nothing from `domain` or `session`.
|
||||
|
||||
Its one job is to answer "what should I be doing?" with the open tasks due today
|
||||
or earlier. That answer surfaces where a work intention is born — the planning
|
||||
screen — as a list of clickable task titles. Clicking a title drops it into the
|
||||
intent field; from there the existing coach pipeline sharpens it into a
|
||||
commitment, unchanged. The task is a **seed**, not a binding link: the session
|
||||
is never tied to a task ID, and nothing is written back to Marvin.
|
||||
|
||||
## 2. The Port
|
||||
|
||||
New package `internal/tasks`, a leaf package like `ai`:
|
||||
|
||||
```go
|
||||
// Task is one to-do item. Primitives only, so tasks stays a leaf package.
|
||||
type Task struct {
|
||||
ID string
|
||||
Title string
|
||||
Day string // "YYYY-MM-DD", or "" if unscheduled
|
||||
}
|
||||
|
||||
// Provider answers "what should I be doing?" — the open tasks due today or
|
||||
// earlier.
|
||||
type Provider interface {
|
||||
Today(ctx context.Context) ([]Task, error)
|
||||
}
|
||||
```
|
||||
|
||||
Files under `internal/tasks/`:
|
||||
|
||||
- `tasks.go` — the `Provider` interface and the `Task` value type.
|
||||
- `marvin.go` — the Amazing Marvin adapter and the JSON parse function.
|
||||
- `tasks_test.go` / `marvin_test.go` — parse tests and adapter tests with a
|
||||
fake command runner.
|
||||
|
||||
## 3. The Marvin Adapter
|
||||
|
||||
The adapter shells out exactly as `ai.claudeBackend` does: `exec.CommandContext`
|
||||
with stdout captured into a buffer and failures wrapped with stderr context
|
||||
(the same shape as `ai.cmdError`). It runs `am --json` (no subcommand, which
|
||||
lists open tasks scheduled for today or earlier), parses the JSON array, and
|
||||
maps each element to a `Task`.
|
||||
|
||||
`am --json` emits an array of objects of this shape (from ampy's
|
||||
`_serialize_task`):
|
||||
|
||||
```json
|
||||
[{"id": "...", "title": "...", "parentId": "...", "day": "YYYY-MM-DD", "done": false}]
|
||||
```
|
||||
|
||||
Only `id`, `title`, and `day` are carried into `Task`; `parentId` is ignored
|
||||
(no hierarchy in M5). Any element with `done: true` is dropped defensively, even
|
||||
though the default listing already returns only open tasks.
|
||||
|
||||
Parsing is a pure function `parse([]byte) ([]Task, error)` so it can be tested
|
||||
directly against fixture strings. The shell-out wrapper holds the resolved
|
||||
`cmd` and `args` and a runner func, so tests can inject a fake runner instead of
|
||||
executing a real process.
|
||||
|
||||
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND`. The environment variable
|
||||
`ANTIDRIFT_MARVIN_CMD` overrides the command; it is space-split so a value like
|
||||
`uv run am` or an absolute path works. Unset or empty defaults to `am`. If `am`
|
||||
cannot be found or fails at call time, `Today` returns an error and the
|
||||
controller degrades to "no tasks panel" — manual planning still works. This is
|
||||
the same degradation contract as the AI backend: misconfiguration never fails
|
||||
startup.
|
||||
|
||||
## 4. Controller Wiring
|
||||
|
||||
The wiring mirrors the planning coach, which already fetches asynchronously and
|
||||
guards against stale results.
|
||||
|
||||
- `SetTasks(p tasks.Provider)` injects the provider, like `SetCoach`. A nil
|
||||
provider turns the feature off.
|
||||
- New `Controller` fields: `tasks tasks.Provider`, `tasksStatus string`
|
||||
(`idle` / `pending` / `ready` / `error`), `tasksList []tasks.Task`, and
|
||||
`tasksGen int` (the generation counter).
|
||||
- `EnterPlanning()` resets the tasks state and, when a provider is set, starts
|
||||
an **asynchronous** `Today()` fetch in a goroutine — the same structure as
|
||||
`RequestCoach`: bump `tasksGen`, set `pending`, `notify()`, then on completion
|
||||
re-acquire the lock and discard the result if the generation is stale or the
|
||||
runtime has left planning. Tasks are **never** fetched on the synchronous
|
||||
`State()` path, which runs on every SSE broadcast.
|
||||
- `State()` projects a `*TasksView{Status string, Tasks []TaskView}` **only
|
||||
while planning**, alongside the existing `CoachView`. `TaskView` carries the
|
||||
JSON-tagged `id`, `title`, and `day`.
|
||||
|
||||
No new runtime states, no new transitions, no change to the state machine.
|
||||
|
||||
## 5. Web / UI
|
||||
|
||||
No new endpoints. Tasks ride in the existing SSE state payload during planning.
|
||||
|
||||
The planning render in `app.js` gains a small "Today" band that lists task
|
||||
titles as clickable chips. Clicking a chip sets the value of `#intent`
|
||||
client-side; the user then reviews it and presses Sharpen, driving the existing
|
||||
`/coach` flow. A `pending` status shows a quiet "loading tasks…" line; `error`
|
||||
or an empty list renders nothing. The seed click is pure client wiring — it adds
|
||||
no POST route and no new server behavior.
|
||||
|
||||
`main.go` gains a Marvin-adapter block parallel to the existing `ai` block: read
|
||||
`ANTIDRIFT_MARVIN_CMD`, construct the adapter, call `ctrl.SetTasks(...)`, and
|
||||
log one line. A construction failure logs "tasks disabled" and proceeds, never
|
||||
fails startup.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **`tasks` package:** table-driven `parse` tests — a valid array, an empty
|
||||
array, malformed JSON, and `done`-filtering. An adapter test that injects a
|
||||
fake runner returning canned stdout (and one returning an error) to confirm
|
||||
the command path and error wrapping, without spawning a process.
|
||||
- **`session` package:** with a fake `Provider`, assert the `tasksStatus`
|
||||
transitions (`pending` → `ready`, and `pending` → `error` on failure) and that
|
||||
`State().Tasks` reflects the fetched list while planning. A nil provider
|
||||
yields no `TasksView`. Leaving planning before the fetch returns discards the
|
||||
stale result (generation guard).
|
||||
- **`web` package:** the existing `web_test.go` stays green (it is
|
||||
markup-agnostic). Add one assertion that planning-state JSON carries the tasks
|
||||
when a provider is set.
|
||||
- stdlib `testing` only (no testify); `go test -race ./...` stays clean; `tasks`
|
||||
stays a leaf package (imports nothing from `domain` / `session` / `evidence`).
|
||||
|
||||
## 7. Out of Scope
|
||||
|
||||
- **Writeback** — marking a task done when a session completes. Deferred per the
|
||||
master design ("outcome writeback … beyond the M5 tasks port").
|
||||
- Projects, categories, and task hierarchy (`parentId` is dropped).
|
||||
- Binding a session to a task ID. The seed is fire-and-forget text.
|
||||
- Due times, labels, estimates, and other Marvin fields.
|
||||
- A manual "refresh tasks" control — the fetch on entering planning is enough
|
||||
for M5.
|
||||
@@ -1,272 +0,0 @@
|
||||
# M6 — Knowledge Port Design
|
||||
|
||||
**Goal:** Add the `knowledge.Source` port — answering "who am I; what are my
|
||||
priorities?" — with a single-config-file adapter (`~/.antidrift/knowledge.md`).
|
||||
The profile text is loaded on entering planning and threaded into the AI
|
||||
**coach** prompt as grounding, so "sharpen this intent" reflects who the user is
|
||||
and what matters to them. A subtle planning-screen indicator shows whether the
|
||||
profile loaded and from where, and lets the user point at a different file.
|
||||
Read-only, graceful degradation.
|
||||
|
||||
**Status:** Design draft 2026-06-01. Implements the deferred `knowledge` port
|
||||
named in `2026-05-31-go-focus-os-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Direction
|
||||
|
||||
The Knowledge port is the fourth real port, after Activity (`evidence`),
|
||||
Advisor (`ai`), and Tasks (`tasks`). It follows the same pattern: a small
|
||||
leaf-package interface, a single adapter, and a fake for tests. It returns
|
||||
primitives only, so it imports nothing from `domain` or `session`.
|
||||
|
||||
Its one job is to answer "who am I; what are my priorities?" — the standing
|
||||
context that does not change session to session (role, current projects, what
|
||||
counts as important, how the user likes to work). Where the Tasks port answers
|
||||
*what should I be doing right now*, the Knowledge port answers *what kind of
|
||||
person, with what priorities, is doing it*. That context exists to make the
|
||||
advisor's judgment less generic.
|
||||
|
||||
**Coach-only grounding (this milestone).** The profile feeds exactly one
|
||||
advisor role: the planning **coach**. Planning is the moment a vague intent is
|
||||
turned into a concrete commitment, it happens at most a few times a day, and the
|
||||
coach call already runs off the hot path — so grounding it is the highest-value,
|
||||
lowest-cost place to start. The live drift judge and the ambient nudge are
|
||||
deliberately left ungrounded in M6: they run on the hot path (debounced/cached
|
||||
per window, every few minutes) and adding profile text to those prompts would
|
||||
raise their token cost for marginal benefit. Extending grounding to those roles
|
||||
is a clean follow-up once M6 proves the wiring (see Out of Scope).
|
||||
|
||||
The profile is **grounding, not instruction**: it informs the coach's proposal
|
||||
but never forces a transition, exactly as the architecture's cortex layer
|
||||
requires. Nothing is written back; the file is read-only.
|
||||
|
||||
## 2. The Port
|
||||
|
||||
New package `internal/knowledge`, a leaf package like `tasks` and `ai`:
|
||||
|
||||
```go
|
||||
// Package knowledge is the Knowledge port: it answers "who am I; what are my
|
||||
// priorities?" by loading the user's standing profile. It imports nothing from
|
||||
// the rest of the app, so it stays a leaf package.
|
||||
package knowledge
|
||||
|
||||
import "context"
|
||||
|
||||
// Profile is the user's standing context. Primitives only, so knowledge stays a
|
||||
// leaf package.
|
||||
type Profile struct {
|
||||
Text string // grounding text; "" when no profile is available
|
||||
Path string // resolved source location, for display
|
||||
}
|
||||
|
||||
// Source answers "who am I; what are my priorities?" — the user's standing
|
||||
// profile that grounds the advisor.
|
||||
type Source interface {
|
||||
// Load returns the user's profile. path selects an explicit location; ""
|
||||
// means the adapter's configured default. A missing source is NOT an error:
|
||||
// it yields an empty-Text Profile so the caller degrades to ungrounded.
|
||||
// Only a real read failure (permissions, unreadable) returns an error.
|
||||
Load(ctx context.Context, path string) (Profile, error)
|
||||
}
|
||||
```
|
||||
|
||||
The `path` parameter (rather than the adapter owning a single fixed path) keeps
|
||||
the adapter stateless and lets the controller own the *selected* path — which
|
||||
the UI can change at runtime — without a mutable field or a type assertion. An
|
||||
empty `path` falls back to the adapter's configured default, and the resolved
|
||||
location comes back in `Profile.Path` for the indicator to display.
|
||||
|
||||
Files under `internal/knowledge/`:
|
||||
|
||||
- `knowledge.go` — the `Source` interface and the `Profile` value type.
|
||||
- `file.go` — the `FileSource` adapter (reads one file) and the small
|
||||
truncation helper.
|
||||
- `file_test.go` — adapter tests against temp files: present, absent, explicit
|
||||
path override, oversize truncation, default-path resolution.
|
||||
|
||||
## 3. The File Adapter
|
||||
|
||||
`FileSource` reads one Markdown/plain-text file and returns its contents as the
|
||||
profile. It is the knowledge analogue of the Marvin adapter, minus the
|
||||
sub-process: a thin, testable wrapper around a single file read.
|
||||
|
||||
```go
|
||||
type FileSource struct {
|
||||
defaultPath string // used when Load is called with path == ""
|
||||
}
|
||||
|
||||
func NewFileSource(defaultPath string) *FileSource
|
||||
func (s *FileSource) Load(ctx context.Context, path string) (knowledge.Profile, error)
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **Path resolution.** `path` if non-empty, else `s.defaultPath`, else the
|
||||
built-in default `~/.antidrift/knowledge.md`. `~` is expanded. The resolved
|
||||
absolute path is returned in `Profile.Path` regardless of outcome, so the
|
||||
indicator can always show *where it looked*.
|
||||
- **Missing file** (`os.IsNotExist`) → `Profile{Path: resolved}` with empty
|
||||
`Text` and **no error**. This is the expected steady state for a user who has
|
||||
not written a profile; it must not look like a failure.
|
||||
- **Read error** (permissions, is-a-directory, I/O) → wrapped error, same
|
||||
`fmt.Errorf("knowledge: ...: %w", err)` shape the other adapters use.
|
||||
- **Size cap.** The text is capped at `maxProfileBytes = 6 KiB` to bound the
|
||||
coach prompt's token cost, truncated on a UTF-8 rune boundary with a trailing
|
||||
`\n…(truncated)` marker. A profile that long is already an outlier; the cap is
|
||||
a guard, not a feature.
|
||||
- **Whitespace-only** file → treated as empty `Text` (absent), so a file of
|
||||
blank lines does not produce a meaningless grounding block.
|
||||
|
||||
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND` / `ANTIDRIFT_MARVIN_CMD`. The
|
||||
environment variable `ANTIDRIFT_KNOWLEDGE_FILE` sets the default path; unset or
|
||||
empty falls back to `~/.antidrift/knowledge.md`. This is the **durable** way to
|
||||
choose the file. The UI selector (§5) is a convenient **session-only** override
|
||||
on top of it. A missing file or read error never fails startup — the daemon
|
||||
logs one line and proceeds ungrounded.
|
||||
|
||||
## 4. Controller Wiring
|
||||
|
||||
The wiring mirrors the planning coach and the tasks fetch: an async load on
|
||||
entering planning, generation-guarded against stale results, projected into
|
||||
`State` only while planning. The one new seam is that the loaded text is also
|
||||
**cached for the coach to consume**, since grounding flows into the coach call.
|
||||
|
||||
- `SetKnowledge(s knowledge.Source)` injects the source, like `SetTasks`. A nil
|
||||
source turns the feature off (no indicator, ungrounded coach).
|
||||
- New `Controller` fields, alongside the tasks fields:
|
||||
`knowledge knowledge.Source`, `knowledgeStatus string`
|
||||
(`idle`/`pending`/`ready`/`absent`/`error`), `knowledgeText string` (the
|
||||
cached grounding the coach reads), `knowledgePath string` (the currently
|
||||
selected path — `""` means the adapter default), `knowledgeChars int`, and
|
||||
`knowledgeGen int` (the generation counter).
|
||||
- `EnterPlanning()` calls `startKnowledgeFetchLocked()` right after
|
||||
`startTasksFetchLocked()`: bump `knowledgeGen`, set `pending`, launch a
|
||||
goroutine that calls `Load(ctx, c.knowledgePath)`, then on completion
|
||||
re-acquire the lock and **discard if the generation is stale or the runtime
|
||||
has left planning**. On success it sets `knowledgeStatus` to `ready` (or
|
||||
`absent` when `Text == ""`), caches `knowledgeText`/`knowledgePath`/
|
||||
`knowledgeChars`, and `notify()`s. Knowledge is **never** loaded on the
|
||||
synchronous `State()` path.
|
||||
- `State()` projects a `*KnowledgeView` **only while planning**, beside the
|
||||
existing `CoachView` and `TasksView`. The view carries status, the resolved
|
||||
path, and a character count — **not the profile text** (it stays server-side;
|
||||
the browser never needs the body, and keeping it off the wire avoids leaking
|
||||
personal context into the SSE payload and the broadcaster).
|
||||
|
||||
```go
|
||||
// KnowledgeView projects the ephemeral planning knowledge state (the standing
|
||||
// profile that grounds the coach). The profile text is intentionally omitted —
|
||||
// only its presence, source path, and size are surfaced.
|
||||
type KnowledgeView struct {
|
||||
Status string `json:"status"` // idle|pending|ready|absent|error
|
||||
Path string `json:"path,omitempty"` // resolved source path, for display + the selector
|
||||
Chars int `json:"chars,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Threading grounding into the coach.** `RequestCoach` captures
|
||||
`grounding := c.knowledgeText` under the lock (next to `coach := c.coach`) and
|
||||
passes it to the coach call. If the knowledge fetch is still in flight when the
|
||||
user presses Sharpen, `knowledgeText` is simply empty for that one call and the
|
||||
coach runs ungrounded — the same graceful-degradation contract as a missing
|
||||
file. No awaiting, no blocking.
|
||||
|
||||
This requires a **signature change** to the `ai.Coach` interface — the one
|
||||
non-additive change in M6:
|
||||
|
||||
```go
|
||||
type Coach interface {
|
||||
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
|
||||
}
|
||||
```
|
||||
|
||||
`Service.Coach` passes `grounding` to `buildPrompt(intent, grounding)`, which
|
||||
prepends an `## About the user` section **only when grounding is non-empty**, so
|
||||
an ungrounded call produces a byte-for-byte unchanged prompt. The ripple is
|
||||
small and mechanical: the impl in `ai/coach.go`, the call site in
|
||||
`session.go`, and the coach tests/fakes that implement the interface. `DriftJudge`
|
||||
and `Nudger` are untouched.
|
||||
|
||||
**Explicit file selection.** `SetKnowledgePath(path string)` stores the selected
|
||||
path and, while planning, kicks off a fresh `startKnowledgeFetchLocked()` so the
|
||||
indicator and cached grounding update immediately. It is session-only — not
|
||||
persisted to the snapshot — so a restart returns to the
|
||||
`ANTIDRIFT_KNOWLEDGE_FILE`/default. (Persisting the selection is a snapshot-schema
|
||||
change deferred to a later milestone; the env var is the durable knob for now.)
|
||||
|
||||
No new runtime states, no new transitions, no change to the state machine, no
|
||||
change to persisted snapshot shape.
|
||||
|
||||
## 5. Web / UI
|
||||
|
||||
The profile load rides in the existing SSE state payload during planning, beside
|
||||
coach and tasks. M6 adds **one** small POST route — the file selector — which is
|
||||
the single deliberate server write in this milestone.
|
||||
|
||||
- **Indicator.** The planning render gains a quiet line under the intent band
|
||||
(near `coachStatus`): `ready` → `grounded by <basename(path)>`; `absent` →
|
||||
`no profile (~/.antidrift/knowledge.md)`; `error` → `profile unreadable`;
|
||||
`pending` → `loading profile…`; nil source / `idle` → nothing. It is
|
||||
ambient and non-blocking, matching the tasks "loading…" treatment — never a
|
||||
button to fight.
|
||||
- **Selector.** A small "change" affordance next to the indicator reveals an
|
||||
input pre-filled with the resolved path; submitting POSTs
|
||||
`/knowledge/path` with a `path` field, which calls `ctrl.SetKnowledgePath`
|
||||
and re-loads. This is the *only* new endpoint. It mutates session-only config,
|
||||
not commitment state, so it sits outside the state machine. Submitting an
|
||||
empty path resets to the default. The control is intentionally minimal; if it
|
||||
proves more than needed, it can ship behind the env var alone (the indicator
|
||||
is the core; the selector is the "maybe").
|
||||
- `main.go` gains a knowledge-adapter block parallel to the `tasks` block: read
|
||||
`ANTIDRIFT_KNOWLEDGE_FILE`, construct `knowledge.NewFileSource(...)`, call
|
||||
`ctrl.SetKnowledge(...)`, and log one line. Construction never fails; a bad
|
||||
path only surfaces (as `absent`/`error`) at load time.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **`knowledge` package:** adapter tests against temp files — a present file
|
||||
(text + resolved path returned), an absent file (empty `Text`, no error,
|
||||
path still reported), an explicit `path` override beating the default, an
|
||||
oversize file truncated on a rune boundary with the marker, a whitespace-only
|
||||
file treated as absent, and a permission/read error wrapped. `~` expansion
|
||||
covered with a synthesized home. stdlib `testing` only; no process spawned.
|
||||
- **`ai` package:** `buildPrompt` includes the `## About the user` block when
|
||||
grounding is non-empty and is byte-identical to the pre-M6 prompt when
|
||||
grounding is empty (a table test pins both). Existing coach/drift/nudge tests
|
||||
updated for the new `Coach` signature (grounding `""`), staying green.
|
||||
- **`session` package:** with a fake `Source`, assert `knowledgeStatus`
|
||||
transitions (`pending` → `ready`, `pending` → `absent` on empty text,
|
||||
`pending` → `error` on failure) and that `State().Knowledge` reflects them
|
||||
while planning and is absent otherwise / with a nil source. Assert
|
||||
`RequestCoach` passes the cached `knowledgeText` to a recording fake coach,
|
||||
and passes `""` when the fetch has not completed. Assert the generation guard
|
||||
discards a load that returns after leaving planning, and that
|
||||
`SetKnowledgePath` re-fetches. A nil source yields no `KnowledgeView` and an
|
||||
ungrounded coach.
|
||||
- **`web` package:** existing tests stay green (markup-agnostic). Add one
|
||||
assertion that planning-state JSON carries the knowledge object (status +
|
||||
path, no text) when a source is set, and one that `POST /knowledge/path`
|
||||
updates the selected path and triggers a re-load.
|
||||
- `go vet ./... && go test -race ./...` stays clean; `knowledge` stays a leaf
|
||||
package (imports only `context` + stdlib `os`/`io`/`path`/`strings`/`unicode`
|
||||
— nothing from `domain`/`session`/`evidence`/`ai`/`web`).
|
||||
|
||||
## 7. Out of Scope
|
||||
|
||||
- **Grounding the drift judge and nudge.** Coach-only in M6. Extending profile
|
||||
grounding to the hot-path roles is a deliberate follow-up, gated on whether
|
||||
the token cost is worth it.
|
||||
- **PKM-directory and CLI adapters.** M6 ships exactly one file adapter. The
|
||||
`path`-parameter port shape leaves room for a directory or `am`-style CLI
|
||||
adapter later without an interface change, but we do not build or abstract for
|
||||
them now (YAGNI).
|
||||
- **Persisting the selected path** across restarts (a snapshot-schema change).
|
||||
The env var is the durable knob; the UI override is session-only.
|
||||
- **Live file watching / auto-reload.** The profile is re-read on each entry to
|
||||
planning (and on explicit reselection); no inotify, no polling.
|
||||
- **Editing the profile from the UI**, structured profile fields (parsing the
|
||||
Markdown into sections), or per-project knowledge. The file is an opaque
|
||||
grounding blob.
|
||||
- **Shipping the profile text to the browser.** Only presence, path, and size
|
||||
cross the wire.
|
||||
@@ -1,194 +0,0 @@
|
||||
# M7 — Reflection: Design
|
||||
|
||||
**Status:** approved
|
||||
**Date:** 2026-06-01
|
||||
**Milestone:** M7 — Reflection (the deferred AI **reviewer** role, promoted into
|
||||
the main loop)
|
||||
|
||||
## Purpose
|
||||
|
||||
Close the loop. Through M6 the system has three live AI roles — Coach,
|
||||
DriftJudge, Nudge — but nothing looks *back*. M7 adds the fourth role, the
|
||||
**Reviewer**: when a session ends, it reflects on what just happened, read
|
||||
against your recent sessions, and produces two short lines —
|
||||
|
||||
- a **recap**, shown on the Review screen (how the session went), and
|
||||
- a **carry-forward**, which grounds the coach the next time you plan (what to
|
||||
do differently).
|
||||
|
||||
This is the "Focus OS Reflection" step from the roadmap. It makes the loop
|
||||
self-reinforcing: each session's takeaway sharpens the next session's plan.
|
||||
|
||||
The whole feature is one cheap async call per session, never blocking, and
|
||||
degrades gracefully — if the AI backend is off or slow, Review/End/Planning
|
||||
behave exactly as they do today.
|
||||
|
||||
## Design constraints
|
||||
|
||||
Two non-negotiables shaped every decision below:
|
||||
|
||||
- **Efficient.** One LLM call per session, fired once on entering Review. The
|
||||
"recent sessions" context is a local file read, not an LLM cost. The prompt
|
||||
carries the finished session plus a few *compact* prior summaries (outcome +
|
||||
top buckets, never raw event logs), so it stays small. The result is computed
|
||||
once and persisted; the next planning cycle reads it from disk and does **not**
|
||||
re-run the reviewer.
|
||||
- **Low friction.** It runs automatically (no "request reflection" button). It is
|
||||
**never blocking** — the **End** button works immediately whether or not the
|
||||
reviewer has returned. The carry-forward is **auto-applied** as coach grounding
|
||||
next time; there is no approve/dismiss step.
|
||||
|
||||
## The new AI role (`ai` package)
|
||||
|
||||
A leaf role that mirrors Coach/DriftJudge/Nudge: it takes only primitives and
|
||||
imports neither `store` nor `session`. The controller is responsible for turning
|
||||
session data into the strings this role consumes.
|
||||
|
||||
```go
|
||||
// Reflection is the reviewer's output: two short, single-line fields.
|
||||
type Reflection struct {
|
||||
Recap string // backward-looking, ≤1 short line — shown on Review
|
||||
CarryForward string // forward-looking, ≤1 short line — grounds the next
|
||||
// coach and is shown on the next Planning screen
|
||||
}
|
||||
|
||||
// Review reflects on a just-finished session, read against recent history.
|
||||
// finished: a compact description of the session that just ended.
|
||||
// history: a compact description of the last few prior sessions ("" if none).
|
||||
func (b *Backend) Review(ctx context.Context, finished, history string) (Reflection, error)
|
||||
```
|
||||
|
||||
- A new prompt builder composes a Reviewer prompt from `finished` and `history`,
|
||||
instructing the model to return **at most one short line per field** so output
|
||||
stays bounded.
|
||||
- A parser extracts the two lines from the backend output, following the
|
||||
existing role-parsing pattern in the `ai` package.
|
||||
- **Graceful fallback:** any error, empty output, or unparseable result yields a
|
||||
zero `Reflection{}` (both fields ""), which the rest of the system treats as
|
||||
"no reflection available." `Review` never panics and never blocks.
|
||||
|
||||
The prompt is built so that an empty `history` (the first-ever session) still
|
||||
produces a sensible recap from `finished` alone.
|
||||
|
||||
## Orchestration (`session.Controller`)
|
||||
|
||||
The controller owns all orchestration, reusing the established async +
|
||||
generation-counter + graceful-degradation pattern already used for the coach,
|
||||
tasks, and knowledge fetches.
|
||||
|
||||
### Fetch on entering Review
|
||||
|
||||
`enterReview` (reached from both `Complete` → `completed` and `Expire` →
|
||||
`expired`) fires `startReflectionFetchLocked()`:
|
||||
|
||||
1. Increment a `reflectionGen` counter and capture it for this fetch.
|
||||
2. Build `finished` from the **in-memory** frozen stats of the session that just
|
||||
ended: next action, success condition, outcome, switch count, and the top app
|
||||
time buckets.
|
||||
3. Build `history` by reading the **last 5 prior** `SessionSummary` records from
|
||||
`audit.jsonl`. The just-finished session is **not** in the chain yet — it is
|
||||
appended only at `End` — so there is no double-counting.
|
||||
4. In a goroutine, call `reviewer.Review(ctx, finished, history)` under a
|
||||
timeout. On return, re-acquire the lock; if `reflectionGen` still matches the
|
||||
captured value, cache `reflectionRecap` and set `carryForward`
|
||||
(**latest-wins**); otherwise discard the result as stale. Then notify.
|
||||
|
||||
The generation guard ensures a slow review from a superseded session can never
|
||||
overwrite a newer one: the most recent *completed* review wins.
|
||||
|
||||
### Grounding the next coach — no interface change
|
||||
|
||||
`grounding` is already a free-form string parameter on `ai.Coach` (added in M6).
|
||||
M7 needs **no** change to the `ai.Coach` signature: in `RequestCoach` the
|
||||
controller composes the existing knowledge profile text **and** the current
|
||||
`carryForward` into that one `grounding` string (profile block, then a short
|
||||
"Last session:" line). M7 is fully additive to the AI interface.
|
||||
|
||||
`carryForward` is latest-wins and survives `End` (it is not cleared with the
|
||||
commitment/stats), so it is present when the next Planning begins.
|
||||
|
||||
### State projection
|
||||
|
||||
The State view gains a small reflection projection so the browser can render it:
|
||||
|
||||
```go
|
||||
type ReflectionView struct {
|
||||
Status string // "idle" | "pending" | "ready" | "absent"
|
||||
Recap string // shown on Review
|
||||
CarryForward string // shown on Planning
|
||||
}
|
||||
```
|
||||
|
||||
- On **Review**, the view carries `Status` + `Recap`.
|
||||
- On **Planning**, the view carries the `CarryForward` line.
|
||||
|
||||
Unlike the M6 *profile* (large and private, deliberately kept off the wire), the
|
||||
reflection lines are short and **exist to be displayed**, so they are
|
||||
intentionally included in the State payload sent to the browser.
|
||||
|
||||
## Persistence
|
||||
|
||||
Snapshot-only, latest-wins. The persisted snapshot JSON gains `reflectionRecap`,
|
||||
`carryForward`, and a small `reflectionStatus` enum (idle/pending/ready/absent).
|
||||
There are **no** changes to `audit.jsonl`, no new files, and no new on-disk
|
||||
format. The permanent, hash-chained `SessionSummary` is untouched.
|
||||
|
||||
One small additive reader is needed on the store:
|
||||
|
||||
```go
|
||||
// RecentSessions returns up to n most-recent summaries from the audit chain,
|
||||
// most-recent first (or oldest-first — fixed by the plan), [] if the log is
|
||||
// absent or empty.
|
||||
func RecentSessions(path string, n int) ([]SessionSummary, error)
|
||||
```
|
||||
|
||||
Today's `readSummaries` is unexported; `RecentSessions` exposes a bounded slice
|
||||
of it for the controller to format into `history`.
|
||||
|
||||
## UI (`web` static assets)
|
||||
|
||||
- **Review screen:** the `Recap` rendered as a subtle line (nudge-band style),
|
||||
with `pending` and `absent` states. The **End** button works immediately
|
||||
regardless of reflection status.
|
||||
- **Planning screen:** the `CarryForward` rendered as a quiet one-liner
|
||||
("Last time: …"), mirroring the M6 knowledge indicator. No buttons, no added
|
||||
clicks anywhere.
|
||||
|
||||
## Daemon wiring (`cmd/antidriftd/main.go`)
|
||||
|
||||
The reviewer backend is wired into the controller (`ctrl.SetReviewer(...)`),
|
||||
gated on AI-backend availability exactly like the other roles. With no backend
|
||||
configured, the reviewer is simply absent and reflection silently does nothing.
|
||||
|
||||
## Error handling / graceful degradation
|
||||
|
||||
- Backend off, error, empty output, unparseable result, or no prior history →
|
||||
nothing is shown; Review, End, and Planning behave exactly as today.
|
||||
- The reviewer **never blocks a transition**. `End` does not wait for it.
|
||||
- The generation counter discards late results from a superseded review.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`ai`:** the Reviewer prompt includes both `finished` and `history`; the
|
||||
parser extracts two lines; error/blank/unparseable input yields an empty
|
||||
`Reflection`.
|
||||
- **`session`:** reflection is fetched on `enterReview`; the result is cached and
|
||||
rides the snapshot; a stale (superseded-generation) result is discarded; the
|
||||
`carryForward` composes into the next coach's `grounding`; everything degrades
|
||||
gracefully when no reviewer is set; `RecentSessions` returns the last *n*
|
||||
summaries in the expected order.
|
||||
- **`web`:** the Review payload carries the `Recap`; the Planning payload carries
|
||||
the `CarryForward`; reflection text is intentionally present on the wire.
|
||||
|
||||
## Out of scope (this milestone)
|
||||
|
||||
- **A durable reflection history** (`reflections.jsonl`). Nothing in the loop
|
||||
needs it — the next coach only needs the latest carry-forward — and the
|
||||
permanent `SessionSummary` still records outcome/buckets/switches for every
|
||||
session. Promoting to a durable log later would be an additive change.
|
||||
- **Changing the `ai.Coach` signature.** Grounding is already a free-form string.
|
||||
- **Refactoring `session.go`.** It is ~1054 lines and M7 adds another per-role
|
||||
async-fetch block; the repetition across the coach/tasks/knowledge/reviewer
|
||||
fetchers is a fair future consolidation target, but extracting it now would
|
||||
destabilize four working roles for no functional gain. M7 follows the
|
||||
established per-role pattern for consistency and reviewability.
|
||||
@@ -1,262 +0,0 @@
|
||||
# M8 (Tier A) — Window-minimize Enforcement: Design
|
||||
|
||||
**Status:** approved
|
||||
**Date:** 2026-06-01
|
||||
**Milestone:** M8 — Enforcement & gate, **Tier A**: the unprivileged
|
||||
`enforce.Guard` port and its window-minimize adapter
|
||||
|
||||
## Purpose
|
||||
|
||||
Make drift finally *cost something*. Through M7 the system tracks, advises, and
|
||||
reflects, but drift is purely advisory: `domain.EnforcementLevel`
|
||||
(observe/warn/block/locked) is defined but **never acted on**, and the drift
|
||||
judge's verdict only changes what the browser shows. M8 turns "track and advise"
|
||||
into "you don't drift in the first place."
|
||||
|
||||
Tier A is the first, gentlest, **unprivileged** slice: when the drift judge
|
||||
confirms the active window is off-task **and** the session opted into
|
||||
enforcement, a new **Guard** minimizes that window, pushing the user back toward
|
||||
an allowed context. It activates the dormant `EnforcementLevel` and establishes
|
||||
the `enforce.Guard` port that the later, privileged tiers reuse.
|
||||
|
||||
It runs entirely in the user's X11 session (no root), follows the port pattern
|
||||
M1 established, degrades gracefully (no X11 / no Guard / Wayland → exactly
|
||||
today's behavior), and never blocks a state transition.
|
||||
|
||||
## Scope
|
||||
|
||||
M8 spans three privilege tiers, each its own spec → plan → build cycle:
|
||||
|
||||
- **Tier A (this spec):** window-minimize. Unprivileged X11 adapter. Low risk.
|
||||
- **Tier B (later):** network blocking via nftables/DNS. Needs root.
|
||||
- **Tier C (later):** the privileged entry gate — guardian process, root-owned
|
||||
IPC, break-glass, gating machine usability on a declared intention. The
|
||||
heaviest step, deliberately last (the original Stage 2 threat boundary).
|
||||
|
||||
This spec covers **only Tier A**. B and C are out of scope here.
|
||||
|
||||
## Architecture shift from the legacy enforcement
|
||||
|
||||
The legacy Rust app was a TUI: `minimize_other(APP_TITLE)` kept *its own window*
|
||||
foregrounded by minimizing everything else, and explicitly skipped the window
|
||||
whose title matched `APP_TITLE`. The Go reimagining is a daemon + browser UI with
|
||||
no single app window to force forward. So Tier A inverts the legacy meaning:
|
||||
rather than minimize-everything-but-us, it **minimizes the active window when
|
||||
that window is the confirmed-drifting one**. The drift pipeline already judges
|
||||
the active window; enforcement simply acts on that judgment.
|
||||
|
||||
## The new port — `enforce.Guard`
|
||||
|
||||
A leaf port mirroring `evidence.Source`: the Guard is a dumb OS primitive that
|
||||
performs an action when told to. **All policy — whether and when to enforce —
|
||||
lives in the controller.** The Guard imports neither `session` nor `domain`.
|
||||
|
||||
```go
|
||||
package enforce
|
||||
|
||||
import "context"
|
||||
|
||||
// Guard makes drift cost something at the OS level. Tier A: minimize the
|
||||
// active window.
|
||||
type Guard interface {
|
||||
// MinimizeActive minimizes the currently-focused window. It is idempotent
|
||||
// (minimizing an already-minimized window is a no-op) and best-effort: it
|
||||
// returns an error for diagnostics, but the caller never blocks on it and
|
||||
// treats failure as "enforcement did nothing this time."
|
||||
MinimizeActive(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
Adapters, mirroring the `evidence` package's split:
|
||||
|
||||
- **`internal/enforce/x11.go`** (`//go:build linux`): resolves the active window
|
||||
with `ewmh.ActiveWindowGet` and iconifies it via `jezek/xgbutil`
|
||||
(`xwindow.Window.Iconify`, which sends the ICCCM `WM_CHANGE_STATE` →
|
||||
`IconicState` client message). Same dependency already in `go.mod` and used by
|
||||
the evidence adapter. **No `xdotool` shell-out.** A fresh `xgbutil.NewConn()`
|
||||
failure (no display, Wayland) yields a Guard whose `MinimizeActive` returns an
|
||||
error every call — the controller logs and continues.
|
||||
- **`internal/enforce/guard_other.go`** (`//go:build !linux`): a no-op Guard
|
||||
whose `MinimizeActive` returns nil, exactly like `evidence/source_other.go`.
|
||||
|
||||
A package-level constructor `NewGuard() Guard` is selected by build tag, matching
|
||||
`evidence.NewSource()`.
|
||||
|
||||
**Rejected alternative:** a policy-aware `Enforce(level, drifting bool, snap)`
|
||||
Guard that decides internally whether to act. That pushes branching logic into
|
||||
the platform-specific, hard-to-unit-test adapter and breaks the leaf pattern
|
||||
`ai` and `evidence` establish. Keeping the Guard a pure primitive keeps all the
|
||||
testable decision logic in the controller, where a fake Guard makes it trivial
|
||||
to assert.
|
||||
|
||||
## Activation — the dormant level, switched on
|
||||
|
||||
`EnforcementLevel` already exists in `domain` but is set nowhere. Tier A plumbs
|
||||
it through:
|
||||
|
||||
- **`StartManualCommitment` gains an `EnforcementLevel` parameter.** The web
|
||||
handler reads it from the planning form. (The existing
|
||||
`domain.NewManual`/`PolicySnapshot` already carry the field; this wires the
|
||||
caller.)
|
||||
- **Planning UI:** an **"Enforce focus"** toggle. On → `block`; off → `warn`
|
||||
(today's advisory behavior). `observe` and `locked` are **not** surfaced in
|
||||
Tier A — `locked` is the Tier C entry gate, and `observe` adds nothing over
|
||||
`warn` for this milestone.
|
||||
- **Effective levels in Tier A:** only `warn` (advisory, no minimize — current
|
||||
behavior) and `block` (minimize on confirmed drift). The Guard acts **iff**
|
||||
the level is `block`.
|
||||
|
||||
The chosen level **rides the snapshot** (latest-wins persistence) so it survives
|
||||
a mid-session daemon restart, exactly like the commitment itself. Runtime drift
|
||||
state remains unpersisted and recomputed after restart, unchanged from M3.
|
||||
|
||||
## Trigger plumbing (`session.Controller`)
|
||||
|
||||
Drift settles as confirmed (`driftStatus = drifting`, via `applyVerdictLocked`)
|
||||
in two existing places:
|
||||
|
||||
1. **Synchronously** in `evaluateDriftLocked`, on a per-class cache hit.
|
||||
2. **Asynchronously** inside the drift-judge closure, after the LLM returns.
|
||||
|
||||
A single helper composes the enforcement action so both paths stay DRY:
|
||||
|
||||
```go
|
||||
// enforceActionLocked returns the minimize thunk when this observation should
|
||||
// be enforced, else nil. Caller holds mu. The returned func performs blocking
|
||||
// X11 I/O and MUST run after the lock is released.
|
||||
func (c *Controller) enforceActionLocked() func() {
|
||||
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||
return nil
|
||||
}
|
||||
guard := c.guard
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||
defer cancel()
|
||||
if err := guard.MinimizeActive(ctx); err != nil {
|
||||
log.Printf("session: enforce minimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`RecordWindow`** already runs the optional judge `launch()` in a goroutine
|
||||
after unlocking. It additionally captures `enforceActionLocked()` under the
|
||||
lock and runs it in a goroutine after unlock (covering the synchronous
|
||||
cached-drift path).
|
||||
- **The judge closure**, after `applyVerdictLocked`, likewise captures and runs
|
||||
the enforcement thunk after it releases `c.mu` (covering the async path).
|
||||
|
||||
Because the action fires on **every** confirmed-drift observation while at
|
||||
`block`, re-raising the window while still off-task minimizes it again — the
|
||||
"repeated while drifting" behavior. `MinimizeActive` is idempotent, so a
|
||||
redundant call on an already-minimized window is harmless.
|
||||
|
||||
No extra runtime state is stored for the UI: the drift projection **derives**
|
||||
the `Enforced` flag from the level and drift status (see State projection), so it
|
||||
is true exactly in the conditions under which the minimize thunk fires.
|
||||
|
||||
### Why off-lock, and the small race we accept
|
||||
|
||||
`MinimizeActive` is an X11 round-trip; running it under `c.mu` would block all
|
||||
controller state for the duration. It runs after unlock, following the M2
|
||||
`RequestCoach` discipline already used by the coach, tasks, knowledge, reviewer,
|
||||
and drift-judge fetches. Between observing drift and the minimize landing, the
|
||||
user could Alt-Tab to an allowed window, which would then be the one minimized.
|
||||
This window is sub-millisecond-to-millisecond; the legacy code had the same
|
||||
property; we accept it.
|
||||
|
||||
## State projection
|
||||
|
||||
The existing `DriftView` (active-only) gains one field so the browser can
|
||||
explain enforcement:
|
||||
|
||||
```go
|
||||
type DriftView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Nudge string `json:"nudge,omitempty"`
|
||||
Enforced bool `json:"enforced,omitempty"` // a minimize fired this drift episode
|
||||
}
|
||||
```
|
||||
|
||||
`Enforced` is derived as `level == block && driftStatus == drifting` — no stored
|
||||
field. It is runtime-only (not persisted), consistent with the rest of the drift
|
||||
projection.
|
||||
|
||||
## Persistence
|
||||
|
||||
Snapshot-only, latest-wins. The persisted snapshot JSON gains the chosen
|
||||
`EnforcementLevel` (so a restart mid-session keeps enforcing). There are **no**
|
||||
changes to `audit.jsonl`, no new files, and no new on-disk format. The
|
||||
hash-chained `SessionSummary` is untouched. (Recording per-session enforcement
|
||||
counts in the permanent summary is a possible future addition, out of scope
|
||||
here.)
|
||||
|
||||
## UI (`web` static assets)
|
||||
|
||||
- **Planning screen:** an **"Enforce focus"** toggle (checkbox), mirroring the
|
||||
quiet style of the M6/M7 indicators. Checked → the commit posts `block`;
|
||||
unchecked → `warn`. A one-line hint explains it ("Minimize off-task windows
|
||||
when you drift.").
|
||||
- **Active screen:** the existing M3 drift band gains a short line —
|
||||
**"Off-task window minimized."** — rendered when `drift.enforced` is true.
|
||||
Reuses the band; no new component. The **End**/**Refocus** buttons are
|
||||
unaffected and always work.
|
||||
|
||||
## Daemon wiring (`cmd/antidriftd/main.go`)
|
||||
|
||||
Construct the Guard with `enforce.NewGuard()` and inject it with
|
||||
`ctrl.SetGuard(g)`, alongside the other adapters. On a platform without the X11
|
||||
adapter (or with no display), the no-op / erroring Guard means enforcement
|
||||
silently does nothing. The startup log line notes enforcement availability.
|
||||
|
||||
## Error handling / graceful degradation
|
||||
|
||||
- No Guard wired, no X11 / Wayland, or `MinimizeActive` error → nothing is
|
||||
enforced; Active, drift, Refocus, and End behave exactly as today. Errors are
|
||||
logged, never surfaced to the user.
|
||||
- The Guard **never blocks a transition**. Minimize runs off-lock in a
|
||||
goroutine under a short timeout.
|
||||
- At `warn` (toggle off) the Guard is never called — identical to today's
|
||||
advisory behavior.
|
||||
|
||||
## Known limitation (accepted, by design)
|
||||
|
||||
Unlike the legacy TUI — which protected its own window by a known title — the Go
|
||||
dashboard lives in a **browser tab with no distinct window**. If the user is
|
||||
*actively viewing the dashboard* in a browser that is not in their allowed
|
||||
classes, that browser is the active window and may be minimized when drift is
|
||||
confirmed. Mitigations: the user adds their browser to allowed classes, and the
|
||||
SSE-backed state is current the moment the dashboard is reopened. We document
|
||||
this rather than build unreliable title-based self-protection; a robust solution
|
||||
belongs to a later tier if it proves necessary.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`enforce`:** the no-op adapter's `MinimizeActive` returns nil. (The X11
|
||||
adapter is integration-tested behind a build tag / display guard like
|
||||
`evidence/x11_integration_test.go`, not in unit tests.)
|
||||
- **`session`:** with a `fakeGuard` recording `MinimizeActive` calls —
|
||||
- minimize fires on confirmed drift at `block`, via **both** the per-class
|
||||
cached path and the async judge path;
|
||||
- minimize does **not** fire at `warn`, with no Guard wired, or while on-task;
|
||||
- the `Enforced` flag appears in the projection precisely while drifting at
|
||||
`block`;
|
||||
- the chosen `EnforcementLevel` survives a snapshot round-trip (restart).
|
||||
- **`web`:** the planning form's enforce toggle posts `block`; the Review/Active
|
||||
payload carries `drift.enforced`; the band note renders.
|
||||
|
||||
## Out of scope (this tier)
|
||||
|
||||
- **Tier B (nftables/DNS) and Tier C (entry gate).** Separate specs.
|
||||
- **`observe`/`locked` levels in the planning UI.** `locked` is the Tier C gate;
|
||||
`observe` is redundant with `warn` here.
|
||||
- **Minimizing all non-allowed windows** (screen-clearing). Tier A acts on the
|
||||
active drifting window only, matching the existing per-active-window drift
|
||||
model. Whole-screen enforcement could return later.
|
||||
- **Per-session enforcement counts in the permanent `SessionSummary`.** Additive
|
||||
later if wanted.
|
||||
- **Title-based self-protection of the dashboard** (see Known limitation).
|
||||
- **Refactoring `session.go`.** Tier A adds one small per-observation hook
|
||||
following the established pattern; the broader async-fetch consolidation
|
||||
remains a future target.
|
||||
@@ -1,201 +0,0 @@
|
||||
# M9 — Tame `session.go`: Design
|
||||
|
||||
**Status:** approved
|
||||
**Date:** 2026-06-01
|
||||
**Milestone:** M9 — Maintainability: split the monolithic `session.go` and
|
||||
consolidate the duplicated async-fetch boilerplate, with zero behavior change
|
||||
|
||||
## Purpose
|
||||
|
||||
The M0–M8 feature arc left `session.Controller` carrying five responsibilities
|
||||
in a single 1278-line file — by far the largest in the codebase (the next is
|
||||
`web.go` at 243). Every milestone's design doc has flagged two specific debts:
|
||||
the file is too big to hold in context at once, and the per-role async-fetch
|
||||
block (capture generation → goroutine → re-lock → latest-wins) is copy-pasted
|
||||
across coach, tasks, knowledge, and reflection.
|
||||
|
||||
M9 pays both down so the controller is easy to extend before any new feature
|
||||
lands. It is a **pure maintainability milestone**: no new behavior, no API
|
||||
change, no exported-symbol rename. Success is the existing test suite passing
|
||||
**green-to-green under `-race`**, before and after.
|
||||
|
||||
## Scope
|
||||
|
||||
Two changes, both confined to `package session`:
|
||||
|
||||
1. **File split** — move declarations (no logic edits) out of the monolith into
|
||||
focused files, each with one clear responsibility.
|
||||
2. **Async-fetch consolidation** — extract the mechanical goroutine dance shared
|
||||
by the four async fetches into one helper, while every real per-role
|
||||
difference stays explicit at the call site.
|
||||
|
||||
Everything else — drift/stats/web/daemon logic, the deferred M8 Tiers B/C —
|
||||
is untouched.
|
||||
|
||||
## The async-fetch helper
|
||||
|
||||
Today four methods (`RequestCoach`, `startTasksFetchLocked`,
|
||||
`startKnowledgeFetchLocked`, `startReflectionFetchLocked`) repeat the same
|
||||
goroutine skeleton: open a timeout context, perform the I/O with no lock held,
|
||||
re-acquire `c.mu`, discard the result if a generation guard says it is stale,
|
||||
otherwise record it and `notify`. The role-specific parts around that skeleton
|
||||
genuinely differ and **must stay per-role**:
|
||||
|
||||
- the generation field (`coachGen` / `tasksGen` / `knowledgeGen` /
|
||||
`reflectionGen`) and status enum;
|
||||
- the stale guard — coach/tasks/knowledge check *gen mismatch **or** left
|
||||
Planning*; reflection checks *gen only* (its carry-forward must survive `End`
|
||||
before the reviewer returns);
|
||||
- the apply logic — tasks/coach are two-branch; knowledge is three-branch and
|
||||
writes `knowledgePath` back; reflection is two-branch and calls
|
||||
`persistLocked`;
|
||||
- pre-goroutine work — reflection reads `history` synchronously under the lock,
|
||||
a happens-before requirement against `End`'s audit-chain append, which must be
|
||||
preserved;
|
||||
- `RequestCoach` manages its own lock and `notify`s the pending state before
|
||||
launching; the `*Locked` variants are launched mid-transition from
|
||||
`EnterPlanning` / `enterReview` while the caller still holds `c.mu`.
|
||||
|
||||
The helper therefore extracts **only** the mechanical dance and takes three
|
||||
closures plus the timeout:
|
||||
|
||||
```go
|
||||
// runFetchAsync launches a generation-guarded background fetch. The caller has
|
||||
// captured its dependencies and (for the *Locked callers) holds c.mu; this
|
||||
// method only spawns the goroutine. fetch performs the I/O with no lock held;
|
||||
// stale reports whether to discard the result; apply records it under the
|
||||
// re-acquired lock (and persists itself when the role requires it).
|
||||
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()
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
Each role keeps its own setup (clear cache, nil-provider short-circuit, `gen++`,
|
||||
pending status, dep capture) and passes `fetch` / `stale` / `apply` closures
|
||||
over its locals. Example (tasks):
|
||||
|
||||
```go
|
||||
func (c *Controller) startTasksFetchLocked() {
|
||||
c.tasksList = nil
|
||||
if c.tasksProvider == nil {
|
||||
c.tasksStatus = tasksIdle
|
||||
return
|
||||
}
|
||||
c.tasksGen++
|
||||
gen := c.tasksGen
|
||||
c.tasksStatus = tasksPending
|
||||
p := c.tasksProvider
|
||||
var list []tasks.Task
|
||||
var err error
|
||||
c.runFetchAsync(tasksTimeout,
|
||||
func(ctx context.Context) { list, err = p.Today(ctx) },
|
||||
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
|
||||
func() {
|
||||
if err != nil {
|
||||
c.tasksStatus = tasksError
|
||||
c.tasksList = nil
|
||||
} else {
|
||||
c.tasksStatus = tasksReady
|
||||
c.tasksList = list
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`runFetchAsync` does not require the lock to be held (it only spawns the
|
||||
goroutine, which re-acquires `c.mu` itself), so it is safe to call both from a
|
||||
`*Locked` caller still inside a transition and from `RequestCoach` after it has
|
||||
unlocked and notified.
|
||||
|
||||
**Rejected alternatives:**
|
||||
|
||||
- *Generic free function* `asyncFetch[T](c, timeout, fetch (ctx)(T,error),
|
||||
stale, apply func(T,error))`. More type-safe — the result flows as a typed
|
||||
value rather than a captured closure var — but Go methods cannot be generic,
|
||||
so it must be a package-level function, and the per-role branches still live
|
||||
in `apply`. The closure-method form is the smaller, lock-idiomatic diff.
|
||||
- *Struct-per-role value* encapsulating `gen` + status + timeout. The most
|
||||
structure but the most churn; four small roles do not justify the machinery
|
||||
(YAGNI).
|
||||
- *Unifying the four `gen` int fields* into one shared counter type. Pure churn
|
||||
for no payoff; out of scope.
|
||||
|
||||
## File decomposition
|
||||
|
||||
All files remain `package session`. **No exported symbol moves out of the
|
||||
package, is renamed, or changes signature** — only the file a declaration lives
|
||||
in changes.
|
||||
|
||||
| File | Responsibility | Declarations |
|
||||
| ---- | -------------- | ------------ |
|
||||
| `session.go` | core controller + lifecycle | `Controller` struct, `New`, `SetClock`/`SetOnChange`/`notify`, `State`/`Deadline`, `persistLocked`, the lifecycle transitions (`EnterPlanning`, `StartManualCommitment`, `Complete`/`Expire`/`enterReview`, `End`, `buildSummaryLocked`), `ErrNotPlanning`/`ErrNotActive` |
|
||||
| `views.go` | UI projection (pure data shaping) | the 11 `*View` types, the `State` type, `stateLocked`, `bucketViews` |
|
||||
| `roles.go` | AI roles + the async-fetch helper | `runFetchAsync`; coach (`SetCoach`, `resetCoachLocked`, `composedGroundingLocked`, `RequestCoach`, `coachErrorMessage`); tasks (`SetTasks`, `startTasksFetchLocked`); knowledge (`SetKnowledge`, `SetKnowledgePath`, `startKnowledgeFetchLocked`); reflection (`SetReviewer`, `startReflectionFetchLocked`, `buildReflectionFinishedLocked`, `buildReflectionHistory`); the coach/tasks/knowledge/reflection timeout + status consts |
|
||||
| `drift.go` | Active-state drift/nudge/enforcement | `RecordWindow`, `evaluateDriftLocked`, `maybeNudgeLocked`, `enforceActionLocked`, `applyVerdictLocked`, `resetDriftLocked`, `OnTask`, `Refocus`, `recordTitleLocked`, `commitmentLineLocked`, `Set{DriftJudge,Guard,Nudge}`, the drift/nudge/enforce consts |
|
||||
| `stats.go` | per-session evidence accounting | `EvidenceStats`, `bucketKey`, `applyEvent`, `replayStats`, `keyFor`, `focusEvent`, `snapFromEvent` |
|
||||
|
||||
The `*ForTest` accessors (`AllowedClassesForTest`, `EnforcementLevelForTest`,
|
||||
`recentTitlesForTest`) stay in regular `.go` files (not `_test.go`) grouped with
|
||||
the cluster they expose, because `internal/web/web_test.go` reaches some of them
|
||||
across the package boundary; a `_test.go` placement would be invisible to that
|
||||
package and break the build. The plan confirms each accessor's call sites before
|
||||
choosing its file.
|
||||
|
||||
Splitting into *sub-packages* is explicitly rejected: every method mutates one
|
||||
`Controller` behind one `sync.Mutex`, so sub-packages would force that private
|
||||
state to be exported. One package across several files is the idiomatic Go shape
|
||||
and keeps the locking invariant intact.
|
||||
|
||||
## Sequencing & safety
|
||||
|
||||
The discipline for a refactor of the controller is behavior preservation proven
|
||||
by the current tests:
|
||||
|
||||
- **Phase 1 — file split (zero logic change).** Move declarations into the new
|
||||
files. `go build ./...` + `go vet ./...` + `go test -race ./...` green. Lowest
|
||||
risk, done first so the structure exists before any logic moves. One small
|
||||
commit per file extracted.
|
||||
- **Phase 2 — consolidation.** Add `runFetchAsync`; migrate the four roles to it
|
||||
**one at a time**, each its own commit, the full `-race` suite green between
|
||||
each migration. Performing this after the split means each migration is a
|
||||
clean diff inside `roles.go` rather than inside the old monolith.
|
||||
|
||||
At no point is the build or the suite left red. The split being first means a
|
||||
mistake there is caught before any semantically-meaningful change is layered on.
|
||||
|
||||
## Testing
|
||||
|
||||
No new behavior means no new behavioral tests are *required*; the contract is
|
||||
green-to-green under `-race`. The plan first **audits** that the existing suite
|
||||
covers each async role's:
|
||||
|
||||
- stale-generation discard,
|
||||
- the not-Planning completion gate (coach/tasks/knowledge),
|
||||
- reflection's gen-only guard plus its `persistLocked` on completion,
|
||||
- knowledge's three-branch apply and `knowledgePath` write-back.
|
||||
|
||||
A characterization test is added **only where the audit finds a real gap** — so
|
||||
the consolidation cannot silently change a path the suite never exercised.
|
||||
Otherwise the existing `session_test.go` and `web_test.go` are the safety net,
|
||||
run after every commit.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any behavior change, API/signature change, or exported-symbol rename.
|
||||
- Unifying the four `gen` int fields into a shared type.
|
||||
- Touching drift/stats/web/daemon **logic** (only moving declarations).
|
||||
- Splitting `session` into sub-packages.
|
||||
- M8 Tiers B (network blocking) and C (privileged entry gate) — separate
|
||||
milestones.
|
||||
@@ -6,6 +6,8 @@ package evidence
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EvidenceHealth records whether the sensor could observe the active window.
|
||||
@@ -32,8 +34,24 @@ type Source interface {
|
||||
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl.
|
||||
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
|
||||
|
||||
// ScrubTitle removes volatile numeric runs so window titles bucket stably.
|
||||
// The raw event log keeps the unscrubbed title; only bucket keys are scrubbed.
|
||||
// extraSpace collapses the whitespace runs left behind by the scrubs below.
|
||||
var extraSpace = regexp.MustCompile(`\s+`)
|
||||
|
||||
// ScrubTitle normalizes a window title into a stable bucket key. It drops
|
||||
// volatile numeric runs (clocks, ratios, percentages) and any non-ASCII symbol
|
||||
// or control rune. The latter covers the animated spinner frames terminal apps
|
||||
// (e.g. Claude Code) cycle through their title — braille and dingbat glyphs are
|
||||
// both Unicode symbol category, so a single task no longer fragments into dozens
|
||||
// of buckets. Letters and digits of every script survive, as do ASCII symbols
|
||||
// (so "C++" is not mangled). Leftover whitespace is collapsed. The raw event log
|
||||
// keeps the unscrubbed title; only bucket keys (and their display) are scrubbed.
|
||||
func ScrubTitle(title string) string {
|
||||
return titleNoise.ReplaceAllString(title, "")
|
||||
title = titleNoise.ReplaceAllString(title, "")
|
||||
title = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || (r > unicode.MaxASCII && unicode.IsSymbol(r)) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, title)
|
||||
return strings.TrimSpace(extraSpace.ReplaceAllString(title, " "))
|
||||
}
|
||||
|
||||
@@ -8,12 +8,19 @@ import (
|
||||
|
||||
func TestScrubTitle(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Plain title", "Plain title"}, // no digits-with-separator: untouched
|
||||
{"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory)
|
||||
{"12.5%", ""}, // percent decimal: stripped whole
|
||||
{"1:23:45 remaining", " remaining"}, // clock ratio: stripped
|
||||
{"-3.0 delta", " delta"}, // leading sign + decimal: stripped
|
||||
{"Download 50.5% complete", "Download complete"}, // embedded percent decimal
|
||||
{"Plain title", "Plain title"}, // no digits-with-separator: untouched
|
||||
{"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory)
|
||||
{"12.5%", ""}, // percent decimal: stripped whole
|
||||
{"1:23:45 remaining", "remaining"}, // clock ratio: stripped, space collapsed
|
||||
{"-3.0 delta", "delta"}, // leading sign + decimal: stripped
|
||||
{"Download 50.5% complete", "Download complete"}, // embedded percent decimal
|
||||
{"⠸ antidrift", "antidrift"}, // braille spinner frame stripped
|
||||
{"⠼ antidrift", "antidrift"}, // different frame collapses to same key
|
||||
{"✳ Check latest commit date", "Check latest commit date"}, // dingbat-star indicator
|
||||
{"⠂ Check latest commit date", "Check latest commit date"}, // same task, different frame
|
||||
{"✳ ⠼ done", "done"}, // multiple glyphs in one title
|
||||
{"C++ build", "C++ build"}, // ASCII symbols kept (not all-symbols)
|
||||
{"日本語 window", "日本語 window"}, // non-ASCII letters preserved
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ScrubTitle(c.in); got != c.want {
|
||||
|
||||
@@ -263,9 +263,10 @@ func (c *Controller) startReflectionFetchLocked() {
|
||||
}
|
||||
|
||||
// buildReflectionFinishedLocked renders the just-finished session as a compact
|
||||
// block for the reviewer. Caller holds mu; c.stats/c.commitment are still set
|
||||
// (End clears them, but enterReview runs before End). Reuses bucketViews for the
|
||||
// per-window totals, already sorted desc by seconds.
|
||||
// block for the reviewer: the commitment, the outcome, on/off/unclassified time
|
||||
// totals, and a top-N on-task list and top-N off-task list. The split fields are
|
||||
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
|
||||
// set (End clears them, but enterReview runs before End).
|
||||
func (c *Controller) buildReflectionFinishedLocked() string {
|
||||
var na, sc string
|
||||
if c.commitment != nil {
|
||||
@@ -280,17 +281,43 @@ func (c *Controller) buildReflectionFinishedLocked() string {
|
||||
fmt.Fprintf(&b, "Success condition: %s\n", sc)
|
||||
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
|
||||
if c.stats != nil {
|
||||
onMin := int64(sumDurations(c.stats.OnTask).Seconds()) / 60
|
||||
offMin := int64(sumDurations(c.stats.OffTask).Seconds()) / 60
|
||||
unclMin := int64(c.stats.unclassified.Seconds()) / 60
|
||||
fmt.Fprintf(&b, "On-task %dm / Off-task %dm / Unclassified %dm\n", onMin, offMin, unclMin)
|
||||
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
|
||||
for i, bv := range bucketViews(c.stats.Buckets) {
|
||||
if i >= reflectionTopBuckets {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
|
||||
}
|
||||
writeBucketList(&b, "On-task", c.stats.OnTask)
|
||||
writeBucketList(&b, "Off-task", c.stats.OffTask)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// writeBucketList renders a labeled, time-descending list of buckets capped at
|
||||
// reflectionTopBuckets. It writes nothing — not even the label — when the map is
|
||||
// empty, so a single-sided session shows only the list that has time.
|
||||
func writeBucketList(b *strings.Builder, label string, m map[bucketKey]time.Duration) {
|
||||
views := bucketViews(m)
|
||||
if len(views) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s:\n", label)
|
||||
for i, bv := range views {
|
||||
if i >= reflectionTopBuckets {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
|
||||
}
|
||||
}
|
||||
|
||||
// sumDurations totals the durations in a bucket map.
|
||||
func sumDurations(m map[bucketKey]time.Duration) time.Duration {
|
||||
var total time.Duration
|
||||
for _, d := range m {
|
||||
total += d
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// buildReflectionHistory renders the last few prior sessions as compact lines.
|
||||
// The just-finished session is not yet in the chain (End appends it), so it is
|
||||
// not double-counted. Returns "" when there is no usable history.
|
||||
|
||||
@@ -249,6 +249,8 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
|
||||
SessionID: sessionID,
|
||||
StartedUnix: now.Unix(),
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
seed := c.latestWindow
|
||||
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
|
||||
@@ -278,7 +280,7 @@ func (c *Controller) enterReview(outcome string) error {
|
||||
}
|
||||
// Flush the final open segment, then freeze accounting.
|
||||
if c.stats != nil && c.stats.hasLast {
|
||||
c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt)
|
||||
c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt))
|
||||
c.stats.hasLast = false
|
||||
}
|
||||
c.runtimeState = next
|
||||
|
||||
@@ -1376,3 +1376,135 @@ func TestEnforcementLevelPersists(t *testing.T) {
|
||||
t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditLockedSplitsByDriftStatus(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.stats = &EvidenceStats{
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
k := bucketKey{Class: "code", Title: "main.go"}
|
||||
|
||||
c.driftStatus = driftOnTask
|
||||
c.creditLocked(k, 10*time.Second)
|
||||
c.driftStatus = driftDrifting
|
||||
c.creditLocked(k, 5*time.Second)
|
||||
c.driftStatus = driftIdle
|
||||
c.creditLocked(k, 3*time.Second)
|
||||
c.driftStatus = driftPending
|
||||
c.creditLocked(k, 2*time.Second)
|
||||
|
||||
if got := c.stats.OnTask[k]; got != 10*time.Second {
|
||||
t.Errorf("OnTask = %v, want 10s", got)
|
||||
}
|
||||
if got := c.stats.OffTask[k]; got != 5*time.Second {
|
||||
t.Errorf("OffTask = %v, want 5s", got)
|
||||
}
|
||||
if got := c.stats.unclassified; got != 5*time.Second { // 3s idle + 2s pending
|
||||
t.Errorf("unclassified = %v, want 5s", got)
|
||||
}
|
||||
if got := c.stats.Buckets[k]; got != 20*time.Second { // total of all four
|
||||
t.Errorf("Buckets total = %v, want 20s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReflectionBlockShowsOnOffSplit(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, nil) // sets commitment ("write report" / "report drafted") and stats
|
||||
|
||||
c.stats.SwitchCount = 2
|
||||
c.stats.OnTask = map[bucketKey]time.Duration{
|
||||
{Class: "code", Title: "main.go"}: 20 * time.Minute,
|
||||
}
|
||||
c.stats.OffTask = map[bucketKey]time.Duration{
|
||||
{Class: "firefox", Title: "YouTube"}: 8 * time.Minute,
|
||||
{Class: "firefox", Title: "Reddit"}: 4 * time.Minute,
|
||||
}
|
||||
c.stats.unclassified = 5 * time.Minute
|
||||
c.outcomePending = "completed"
|
||||
|
||||
c.mu.Lock()
|
||||
got := c.buildReflectionFinishedLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
want := "Next action: write report\n" +
|
||||
"Success condition: report drafted\n" +
|
||||
"Outcome: completed\n" +
|
||||
"On-task 20m / Off-task 12m / Unclassified 5m\n" +
|
||||
"Context switches: 2\n" +
|
||||
"On-task:\n" +
|
||||
"- code · main.go: 20m\n" +
|
||||
"Off-task:\n" +
|
||||
"- firefox · YouTube: 8m\n" +
|
||||
"- firefox · Reddit: 4m"
|
||||
if got != want {
|
||||
t.Errorf("reflection block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReflectionBlockOmitsEmptyOffTaskList(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, nil)
|
||||
|
||||
c.stats.OnTask = map[bucketKey]time.Duration{
|
||||
{Class: "code", Title: "main.go"}: 20 * time.Minute,
|
||||
}
|
||||
c.stats.OffTask = map[bucketKey]time.Duration{} // none
|
||||
c.stats.unclassified = 0
|
||||
c.outcomePending = "completed"
|
||||
|
||||
c.mu.Lock()
|
||||
got := c.buildReflectionFinishedLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
if strings.Contains(got, "Off-task:") {
|
||||
t.Errorf("fully on-task session must omit the Off-task list, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "On-task 20m / Off-task 0m / Unclassified 0m") {
|
||||
t.Errorf("totals line missing or wrong, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordWindowCreditsSplitFaithfully(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||
c.SetClock(clk.fn())
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}})
|
||||
|
||||
c.RecordWindow(snap("code", "main.go")) // latest window before start
|
||||
startActive(t, c, []string{"code"}) // seeds code/main.go at t=1000, driftStatus idle
|
||||
|
||||
clk.advance(5 * time.Minute)
|
||||
c.RecordWindow(snap("code", "main.go")) // credits 5m to code/main.go while idle -> unclassified; now on-task (local match)
|
||||
clk.advance(10 * time.Minute)
|
||||
c.RecordWindow(snap("code", "main.go")) // credits 10m on-task
|
||||
clk.advance(10 * time.Minute)
|
||||
c.RecordWindow(snap("firefox", "YouTube")) // credits 10m on-task (total 20m); firefox -> judge (pending)
|
||||
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
|
||||
|
||||
clk.advance(8 * time.Minute)
|
||||
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting
|
||||
clk.advance(4 * time.Minute)
|
||||
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
|
||||
t.Fatalf("complete: %v", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
got := c.buildReflectionFinishedLocked()
|
||||
c.mu.Unlock()
|
||||
|
||||
want := "Next action: write report\n" +
|
||||
"Success condition: report drafted\n" +
|
||||
"Outcome: completed\n" +
|
||||
"On-task 20m / Off-task 12m / Unclassified 5m\n" +
|
||||
"Context switches: 2\n" +
|
||||
"On-task:\n" +
|
||||
"- code · main.go: 20m\n" +
|
||||
"Off-task:\n" +
|
||||
"- firefox · YouTube: 8m\n" +
|
||||
"- firefox · Reddit: 4m"
|
||||
if got != want {
|
||||
t.Errorf("faithful split mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ type bucketKey struct{ Class, Title string }
|
||||
|
||||
// EvidenceStats is the in-memory accounting for the current session only.
|
||||
type EvidenceStats struct {
|
||||
SessionID string
|
||||
StartedUnix int64
|
||||
Buckets map[bucketKey]time.Duration
|
||||
SwitchCount int
|
||||
Current evidence.WindowSnapshot
|
||||
lastFocusAt time.Time
|
||||
lastKey bucketKey
|
||||
hasLast bool
|
||||
SessionID string
|
||||
StartedUnix int64
|
||||
Buckets map[bucketKey]time.Duration // total time per bucket (unchanged)
|
||||
OnTask map[bucketKey]time.Duration // on-task portion per bucket
|
||||
OffTask map[bucketKey]time.Duration // off-task portion per bucket
|
||||
unclassified time.Duration // idle/pending time; aggregate only
|
||||
SwitchCount int
|
||||
Current evidence.WindowSnapshot
|
||||
lastFocusAt time.Time
|
||||
lastKey bucketKey
|
||||
hasLast bool
|
||||
}
|
||||
|
||||
// applyEvent advances stats by one observation: it credits the prior segment to
|
||||
@@ -26,7 +29,7 @@ type EvidenceStats struct {
|
||||
// current window. Used by both live tracking and crash replay. Caller holds mu.
|
||||
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
||||
if c.stats.hasLast {
|
||||
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
|
||||
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
|
||||
}
|
||||
newKey := keyFor(snap)
|
||||
if c.stats.hasLast && newKey != c.stats.lastKey {
|
||||
@@ -38,6 +41,24 @@ func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
||||
c.stats.Current = snap
|
||||
}
|
||||
|
||||
// creditLocked credits duration d to bucket k: always to the running total, and
|
||||
// to the on/off/unclassified split per the live drift status — which, at every
|
||||
// credit site, is the classification of the segment being credited (applyEvent
|
||||
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
|
||||
// before the state transition). idle/pending route to unclassified: honest, never
|
||||
// falsely on-task. Caller holds mu.
|
||||
func (c *Controller) creditLocked(k bucketKey, d time.Duration) {
|
||||
c.stats.Buckets[k] += d
|
||||
switch c.driftStatus {
|
||||
case driftOnTask:
|
||||
c.stats.OnTask[k] += d
|
||||
case driftDrifting:
|
||||
c.stats.OffTask[k] += d
|
||||
default: // driftIdle, driftPending
|
||||
c.stats.unclassified += d
|
||||
}
|
||||
}
|
||||
|
||||
// replayStats rebuilds in-memory stats from the raw session log after a crash.
|
||||
func (c *Controller) replayStats(sessionID string) {
|
||||
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
||||
@@ -46,6 +67,8 @@ func (c *Controller) replayStats(sessionID string) {
|
||||
SessionID: sessionID,
|
||||
StartedUnix: c.clock().Unix(),
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -53,7 +76,12 @@ func (c *Controller) replayStats(sessionID string) {
|
||||
SessionID: sessionID,
|
||||
StartedUnix: events[0].AtUnixMillis / 1000,
|
||||
Buckets: map[bucketKey]time.Duration{},
|
||||
OnTask: map[bucketKey]time.Duration{},
|
||||
OffTask: map[bucketKey]time.Duration{},
|
||||
}
|
||||
// Drift status is not persisted, so replayed pre-crash segments credit to
|
||||
// unclassified (driftStatus is driftIdle here). Totals in Buckets are exact;
|
||||
// only the on/off split degrades — honest, never falsely on-task.
|
||||
for _, e := range events {
|
||||
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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
|
||||
// leaf type package: it imports nothing else in the app so any layer may depend
|
||||
// on the Settings value without pulling in adapters.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
|
||||
// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400
|
||||
// without importing the ai package.
|
||||
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
|
||||
|
||||
// Settings is the user-editable configuration. Field names mirror the env vars
|
||||
// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE.
|
||||
type Settings struct {
|
||||
AIBackend string `json:"ai_backend"`
|
||||
MarvinCmd string `json:"marvin_cmd"`
|
||||
KnowledgePath string `json:"knowledge_path"`
|
||||
}
|
||||
|
||||
// DefaultPath returns ~/.antidrift/settings.json.
|
||||
func DefaultPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".antidrift", "settings.json"), nil
|
||||
}
|
||||
|
||||
// Load reads a settings file. A missing file is an error; callers treat that as
|
||||
// "first run" and seed instead.
|
||||
func Load(path string) (Settings, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
var s Settings
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Save writes settings atomically (temp file + rename), creating the directory
|
||||
// if needed. Mirrors store.Save.
|
||||
func Save(path string, s Settings) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying
|
||||
// the same defaults the daemon used before the settings file existed. An unset
|
||||
// AI backend defaults to "claude" (matching ai.NewBackend("")).
|
||||
func SeedFromEnv() Settings {
|
||||
backend := os.Getenv("ANTIDRIFT_AI_BACKEND")
|
||||
if backend == "" {
|
||||
backend = "claude"
|
||||
}
|
||||
return Settings{
|
||||
AIBackend: backend,
|
||||
MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"),
|
||||
KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||
if err := Save(path, want); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("round trip = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFileIsError(t *testing.T) {
|
||||
_, err := Load(filepath.Join(t.TempDir(), "nope.json"))
|
||||
if err == nil {
|
||||
t.Fatal("want error loading missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvReadsVars(t *testing.T) {
|
||||
t.Setenv("ANTIDRIFT_AI_BACKEND", "codex")
|
||||
t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am")
|
||||
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md")
|
||||
got := SeedFromEnv()
|
||||
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"}
|
||||
if got != want {
|
||||
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedFromEnvDefaultsBackend(t *testing.T) {
|
||||
t.Setenv("ANTIDRIFT_AI_BACKEND", "")
|
||||
t.Setenv("ANTIDRIFT_MARVIN_CMD", "")
|
||||
t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "")
|
||||
got := SeedFromEnv()
|
||||
if got.AIBackend != "claude" {
|
||||
t.Errorf("default backend = %q, want claude", got.AIBackend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCreatesDir(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "settings.json")
|
||||
if err := Save(path, Settings{AIBackend: "claude"}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("file not written: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetSettings injects the persisted settings, their file path, and the applier
|
||||
// closure (built by main, which owns adapter construction). Mirrors the
|
||||
// controller's Set* injection so web never imports ai/tasks/knowledge.
|
||||
func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) {
|
||||
s.settingsMu.Lock()
|
||||
s.settingsPath = path
|
||||
s.settings = current
|
||||
s.applyFn = apply
|
||||
s.settingsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(c *gin.Context) {
|
||||
s.settingsMu.Lock()
|
||||
cur := s.settings
|
||||
s.settingsMu.Unlock()
|
||||
c.JSON(http.StatusOK, cur)
|
||||
}
|
||||
|
||||
// handlePostSettings applies the new settings first, then persists them. The
|
||||
// applier checks the backend before mutating any state, so an invalid backend
|
||||
// yields 400 with nothing saved and the prior wiring intact. If Save fails after
|
||||
// a successful apply, the running adapters are already rewired but the file and
|
||||
// the in-memory copy are left unchanged; that surfaces as 500.
|
||||
func (s *Server) handlePostSettings(c *gin.Context) {
|
||||
var req settings.Settings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
s.applyMu.Lock()
|
||||
defer s.applyMu.Unlock()
|
||||
s.settingsMu.Lock()
|
||||
apply := s.applyFn
|
||||
path := s.settingsPath
|
||||
s.settingsMu.Unlock()
|
||||
if apply == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
||||
return
|
||||
}
|
||||
if err := apply(req); err != nil {
|
||||
// Apply validates the backend before mutating anything, so any error
|
||||
// (invalid backend or otherwise) means nothing changed; surface it as 400.
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := settings.Save(path, req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.settingsMu.Lock()
|
||||
s.settings = req
|
||||
s.settingsMu.Unlock()
|
||||
s.broadcast()
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
type browseEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
type browseResponse struct {
|
||||
Dir string `json:"dir"`
|
||||
Parent string `json:"parent"`
|
||||
Entries []browseEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleBrowse lists subdirectories plus .md files under dir, so the UI can build
|
||||
// a file picker that returns a real server-side path. Dotfiles are NOT hidden —
|
||||
// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail
|
||||
// beyond OS permissions (same trust boundary as the rest of the UI).
|
||||
func (s *Server) handleBrowse(c *gin.Context) {
|
||||
dir := strings.TrimSpace(c.Query("dir"))
|
||||
if dir == "" {
|
||||
dir = s.browseStart()
|
||||
}
|
||||
dir = filepath.Clean(dir)
|
||||
infos, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp := browseResponse{Dir: dir, Parent: filepath.Dir(dir), Entries: []browseEntry{}}
|
||||
for _, e := range infos {
|
||||
name := e.Name()
|
||||
full := filepath.Join(dir, name)
|
||||
if e.IsDir() {
|
||||
resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full, IsDir: true})
|
||||
} else if strings.HasSuffix(name, ".md") {
|
||||
resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full})
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// browseStart picks the directory of the current knowledge path, else $HOME.
|
||||
func (s *Server) browseStart() string {
|
||||
s.settingsMu.Lock()
|
||||
kp := s.settings.KnowledgePath
|
||||
s.settingsMu.Unlock()
|
||||
if kp != "" {
|
||||
return filepath.Dir(kp)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return home
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
)
|
||||
|
||||
// fakeApplier records the last settings it was asked to apply and can be told to
|
||||
// reject with ErrInvalidBackend.
|
||||
type fakeApplier struct {
|
||||
called int
|
||||
last settings.Settings
|
||||
reject bool
|
||||
}
|
||||
|
||||
func (f *fakeApplier) apply(s settings.Settings) error {
|
||||
if f.reject {
|
||||
return settings.ErrInvalidBackend
|
||||
}
|
||||
f.called++
|
||||
f.last = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetSettingsReturnsCurrent(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var got settings.Settings
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got != cur {
|
||||
t.Errorf("got %+v, want %+v", got, cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}`
|
||||
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)
|
||||
}
|
||||
if fa.last.AIBackend != "codex" {
|
||||
t.Errorf("applied backend = %q, want codex", fa.last.AIBackend)
|
||||
}
|
||||
saved, err := settings.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("settings file not loadable: %v", err)
|
||||
}
|
||||
if saved.AIBackend != "codex" || saved.MarvinCmd != "uv run am" || saved.KnowledgePath != "/tmp/k.md" {
|
||||
t.Errorf("saved settings = %+v, want codex/uv run am//tmp/k.md", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{reject: true}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
w := post(t, r, "/settings", `{"ai_backend":"bogus"}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("settings file should not exist after rejected save, stat err = %v", err)
|
||||
}
|
||||
if fa.called != 0 {
|
||||
t.Errorf("applier recorded %d successful applies, want 0", fa.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
w := post(t, r, "/settings", `{not json`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if fa.called != 0 {
|
||||
t.Errorf("applier called on invalid json; want 0, got %d", fa.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newTestServer(t)
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||
r := s.Router()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Dir string `json:"dir"`
|
||||
Parent string `json:"parent"`
|
||||
Entries []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Parent != filepath.Dir(dir) {
|
||||
t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir))
|
||||
}
|
||||
if resp.Dir != dir {
|
||||
t.Errorf("dir = %q, want %q", resp.Dir, dir)
|
||||
}
|
||||
var names []string
|
||||
hasSub, hasMd, hasTxt := false, false, false
|
||||
for _, e := range resp.Entries {
|
||||
names = append(names, e.Name)
|
||||
switch e.Name {
|
||||
case "sub":
|
||||
hasSub = e.IsDir
|
||||
if e.Path != filepath.Join(dir, e.Name) {
|
||||
t.Errorf("sub path = %q, want %q", e.Path, filepath.Join(dir, e.Name))
|
||||
}
|
||||
case "profile.md":
|
||||
hasMd = !e.IsDir
|
||||
if e.Path != filepath.Join(dir, e.Name) {
|
||||
t.Errorf("profile.md path = %q, want %q", e.Path, filepath.Join(dir, e.Name))
|
||||
}
|
||||
case "notes.txt":
|
||||
hasTxt = true
|
||||
}
|
||||
}
|
||||
if !hasSub || !hasMd {
|
||||
t.Errorf("missing dir or .md entry; names = %v", names)
|
||||
}
|
||||
if hasTxt {
|
||||
t.Errorf(".txt should be filtered out; names = %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseBadDirIs400(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||
r := s.Router()
|
||||
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,6 @@ input:focus { outline: 0; border-color: var(--accent); }
|
||||
display: flex; justify-content: space-between; padding: 2px 0;
|
||||
font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.switches { font-size: 12px; color: var(--ink-dim); margin-top: 8px; }
|
||||
|
||||
/* Review summary band */
|
||||
.summary { font-size: 14px; }
|
||||
@@ -128,3 +127,46 @@ input:focus { outline: 0; border-color: var(--accent); }
|
||||
.enforce-note { opacity: 0.8; font-size: 0.85em; }
|
||||
.enforce-toggle { display: flex; align-items: center; gap: 0.4em; }
|
||||
.hint { opacity: 0.7; font-size: 0.85em; margin: 0.2em 0 0.6em; }
|
||||
|
||||
/* Settings: header gear + overlay modal */
|
||||
.gear {
|
||||
background: none; border: 0; color: var(--ink-dim); cursor: pointer;
|
||||
font-size: 14px; padding: 0 4px; vertical-align: middle;
|
||||
}
|
||||
.gear:hover { color: var(--accent); }
|
||||
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding: 8vh 16px; z-index: 10;
|
||||
}
|
||||
.overlay[hidden] { display: none; }
|
||||
.modal {
|
||||
width: 100%; max-width: 520px; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 14px; padding: 20px;
|
||||
}
|
||||
.modal h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.modal-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal-actions .btn { margin-top: 0; }
|
||||
.path-row { display: flex; gap: 8px; align-items: center; }
|
||||
.path-row input { flex: 1; }
|
||||
.path-row .btn { margin-top: 0; white-space: nowrap; }
|
||||
.set-error { color: var(--danger); font-size: 13px; margin-top: 8px; min-height: 1em; }
|
||||
|
||||
.browse {
|
||||
margin-top: 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--bg); max-height: 260px; overflow-y: auto;
|
||||
}
|
||||
.browse[hidden] { display: none; }
|
||||
.browse-dir {
|
||||
padding: 8px 10px; font-size: 12px; color: var(--ink-dim);
|
||||
font-family: ui-monospace, monospace; border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; background: var(--bg);
|
||||
}
|
||||
.browse-list { list-style: none; margin: 0; padding: 4px 0; }
|
||||
.browse-list button {
|
||||
width: 100%; text-align: left; background: none; border: 0; cursor: pointer;
|
||||
color: var(--ink); font: inherit; font-size: 13px; padding: 5px 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.browse-list button:hover { background: var(--line); color: var(--accent); }
|
||||
|
||||
+120
-7
@@ -35,7 +35,7 @@ function setStateAttr(state) {
|
||||
}
|
||||
|
||||
function evidenceBlock(ev) {
|
||||
if (!ev) return '';
|
||||
if (!ev) return `<div class="band evidence" id="evidence" hidden></div>`;
|
||||
const health = ev.available
|
||||
? `<span class="health-ok">tracking</span>`
|
||||
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
||||
@@ -43,13 +43,20 @@ function evidenceBlock(ev) {
|
||||
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
|
||||
const rows = (ev.buckets || []).map(b =>
|
||||
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
|
||||
return `<div class="band evidence">
|
||||
return `<div class="band evidence" id="evidence">
|
||||
<div class="now">now ${now} ${health}</div>
|
||||
<ul class="buckets">${rows}</ul>
|
||||
<div class="switches">context switches: ${ev.switch_count || 0}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// updateActiveEvidence repaints the live evidence band (current window, per-window
|
||||
// buckets, switch count) on every active tick. The band carries no event listeners,
|
||||
// so swapping outerHTML wholesale is safe and keeps a single render source.
|
||||
function updateActiveEvidence(state) {
|
||||
const el = document.getElementById('evidence');
|
||||
if (el) el.outerHTML = evidenceBlock(state.evidence);
|
||||
}
|
||||
|
||||
// reviewSummary renders a presentational recap from already-available state:
|
||||
// the commitment plus the per-window evidence buckets. No new backend data.
|
||||
function reviewSummary(ev) {
|
||||
@@ -174,10 +181,7 @@ function updatePlanningKnowledge(k) {
|
||||
else line = 'profile unreadable';
|
||||
el.innerHTML =
|
||||
`<span class="knowline meta">${line}</span> <button type="button" class="link" id="knowChange">change</button>`;
|
||||
document.getElementById('knowChange').onclick = () => {
|
||||
const next = prompt('Profile file path (blank = default):', k.path || '');
|
||||
if (next !== null) post('/knowledge/path', { path: next.trim() });
|
||||
};
|
||||
document.getElementById('knowChange').onclick = openSettings;
|
||||
}
|
||||
|
||||
// reflectionBlock renders the reviewer's recap on the Review screen. idle/nil or
|
||||
@@ -213,6 +217,7 @@ function render(state) {
|
||||
}
|
||||
if (rs === 'active' && renderedState === 'active') {
|
||||
updateActiveDrift(state);
|
||||
updateActiveEvidence(state);
|
||||
return;
|
||||
}
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||
@@ -306,3 +311,111 @@ function render(state) {
|
||||
const es = new EventSource('/events');
|
||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
||||
|
||||
// ---- Settings overlay ----
|
||||
function openSettings() {
|
||||
fetch('/settings').then(r => r.json()).then(s => {
|
||||
const ov = document.getElementById('settingsOverlay');
|
||||
ov.innerHTML = `
|
||||
<div class="modal">
|
||||
<h2>Settings</h2>
|
||||
<label>AI backend</label>
|
||||
<select id="setBackend">
|
||||
<option value="claude">claude</option>
|
||||
<option value="codex">codex</option>
|
||||
</select>
|
||||
<label>Marvin tasks command</label>
|
||||
<input id="setMarvin" placeholder="uv run am">
|
||||
<label>Knowledge profile path</label>
|
||||
<div class="path-row">
|
||||
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
|
||||
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
||||
</div>
|
||||
<div id="browsePane" class="browse" hidden></div>
|
||||
<div id="setError" class="set-error"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-ghost" id="setCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="setSave">Save</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('setBackend').value = s.ai_backend || 'claude';
|
||||
document.getElementById('setMarvin').value = s.marvin_cmd || '';
|
||||
document.getElementById('setKnow').value = s.knowledge_path || '';
|
||||
ov.hidden = false;
|
||||
document.getElementById('setCancel').onclick = closeSettings;
|
||||
document.getElementById('setSave').onclick = saveSettings;
|
||||
document.getElementById('setBrowse').onclick = () =>
|
||||
loadBrowse(parentDir(document.getElementById('setKnow').value));
|
||||
}).catch(() => {
|
||||
const ov = document.getElementById('settingsOverlay');
|
||||
ov.innerHTML = '<div class="modal"><div class="set-error">Could not load settings.</div>' +
|
||||
'<div class="modal-actions"><button type="button" class="btn btn-ghost" id="setClose">Close</button></div></div>';
|
||||
ov.hidden = false;
|
||||
document.getElementById('setClose').onclick = closeSettings;
|
||||
});
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
const ov = document.getElementById('settingsOverlay');
|
||||
ov.hidden = true;
|
||||
ov.innerHTML = '';
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const body = {
|
||||
ai_backend: document.getElementById('setBackend').value,
|
||||
marvin_cmd: document.getElementById('setMarvin').value.trim(),
|
||||
knowledge_path: document.getElementById('setKnow').value.trim(),
|
||||
};
|
||||
post('/settings', body).then(r => {
|
||||
if (r.ok) { closeSettings(); return; }
|
||||
return r.json().then(e => {
|
||||
document.getElementById('setError').textContent = e.error || 'save failed';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
if (!path) return '';
|
||||
const i = path.lastIndexOf('/');
|
||||
if (i <= 0) return '/';
|
||||
return path.slice(0, i);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function loadBrowse(dir) {
|
||||
const pane = document.getElementById('browsePane');
|
||||
pane.hidden = false;
|
||||
fetch('/fs/browse?dir=' + encodeURIComponent(dir || '')).then(r => {
|
||||
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'cannot read directory'); });
|
||||
return r.json();
|
||||
}).then(d => {
|
||||
const items = [];
|
||||
if (d.parent && d.parent !== d.dir) {
|
||||
items.push(`<li><button type="button" data-dir="${esc(d.parent)}">../</button></li>`);
|
||||
}
|
||||
for (const e of d.entries) {
|
||||
if (e.is_dir) {
|
||||
items.push(`<li><button type="button" data-dir="${esc(e.path)}">${esc(e.name)}/</button></li>`);
|
||||
} else {
|
||||
items.push(`<li><button type="button" data-file="${esc(e.path)}">${esc(e.name)}</button></li>`);
|
||||
}
|
||||
}
|
||||
pane.innerHTML = `<div class="browse-dir">${esc(d.dir)}</div><ul class="browse-list">${items.join('')}</ul>`;
|
||||
pane.querySelectorAll('button[data-dir]').forEach(b =>
|
||||
b.onclick = () => loadBrowse(b.getAttribute('data-dir')));
|
||||
pane.querySelectorAll('button[data-file]').forEach(b =>
|
||||
b.onclick = () => {
|
||||
document.getElementById('setKnow').value = b.getAttribute('data-file');
|
||||
pane.hidden = true;
|
||||
pane.innerHTML = '';
|
||||
});
|
||||
}).catch(err => {
|
||||
pane.innerHTML = `<div class="set-error">${err.message}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('gear').onclick = openSettings;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
@@ -4,12 +4,15 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AntiDrift</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<h1>AntiDrift</h1>
|
||||
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
+19
-19
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/session"
|
||||
"antidrift/internal/settings"
|
||||
"antidrift/internal/statemachine"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,6 +30,12 @@ type Server struct {
|
||||
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
|
||||
settingsMu sync.Mutex
|
||||
settings settings.Settings
|
||||
settingsPath string
|
||||
applyFn func(settings.Settings) error
|
||||
applyMu sync.Mutex // serializes POST /settings apply+save
|
||||
}
|
||||
|
||||
func NewServer(ctrl *session.Controller) *Server {
|
||||
@@ -40,6 +47,9 @@ func NewServer(ctrl *session.Controller) *Server {
|
||||
func (s *Server) Router() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
// antidriftd binds localhost only and sits behind no proxy, so trust no
|
||||
// forwarded-IP headers. This also silences gin's trust-all-proxies warning.
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
|
||||
sub, _ := fs.Sub(staticFS, "static")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
@@ -51,6 +61,12 @@ func (s *Server) Router() *gin.Engine {
|
||||
r.GET("/app.js", func(c *gin.Context) {
|
||||
c.FileFromFS("/app.js", http.FS(sub))
|
||||
})
|
||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||
c.FileFromFS("/favicon.ico", http.FS(sub))
|
||||
})
|
||||
r.GET("/favicon.png", func(c *gin.Context) {
|
||||
c.FileFromFS("/favicon.png", http.FS(sub))
|
||||
})
|
||||
r.GET("/events", s.handleEvents)
|
||||
r.POST("/planning", s.handlePlanning)
|
||||
r.POST("/coach", s.handleCoach)
|
||||
@@ -59,7 +75,9 @@ func (s *Server) Router() *gin.Engine {
|
||||
r.POST("/end", s.handleEnd)
|
||||
r.POST("/refocus", s.handleRefocus)
|
||||
r.POST("/ontask", s.handleOnTask)
|
||||
r.POST("/knowledge/path", s.handleKnowledgePath)
|
||||
r.GET("/settings", s.handleGetSettings)
|
||||
r.POST("/settings", s.handlePostSettings)
|
||||
r.GET("/fs/browse", s.handleBrowse)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -131,24 +149,6 @@ func (s *Server) handleCommitment(c *gin.Context) {
|
||||
s.respond(c, err)
|
||||
}
|
||||
|
||||
type knowledgePathRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// handleKnowledgePath repoints the profile file at runtime (session-only). It
|
||||
// mutates config, not commitment state, so it never returns a transition error;
|
||||
// it just sets the path, re-loads, and broadcasts the refreshed state.
|
||||
func (s *Server) handleKnowledgePath(c *gin.Context) {
|
||||
var req knowledgePathRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
s.ctrl.SetKnowledgePath(req.Path)
|
||||
s.broadcast()
|
||||
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
||||
}
|
||||
|
||||
func (s *Server) handleComplete(c *gin.Context) {
|
||||
s.cancelExpiry()
|
||||
s.respond(c, s.ctrl.Complete())
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/knowledge"
|
||||
"antidrift/internal/session"
|
||||
"antidrift/internal/settings"
|
||||
"antidrift/internal/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -215,6 +216,8 @@ func TestServesStaticAssets(t *testing.T) {
|
||||
}{
|
||||
{"/app.css", "css"},
|
||||
{"/app.js", "javascript"},
|
||||
{"/favicon.ico", "image"},
|
||||
{"/favicon.png", "image"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
@@ -396,12 +399,17 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
|
||||
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
|
||||
settings.Settings{}, func(ss settings.Settings) error {
|
||||
s.ctrl.SetKnowledgePath(ss.KnowledgePath)
|
||||
return nil
|
||||
})
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
if w := post(t, r, "/knowledge/path", `{"path":"/custom/profile.md"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("/knowledge/path code %d body %s", w.Code, w.Body.String())
|
||||
if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("/settings code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
|
||||
Reference in New Issue
Block a user