Compare commits
7 Commits
e3cfe8c70c
...
5adab985a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 5adab985a7 | |||
| 4cc1edfc60 | |||
| 47f1167247 | |||
| 41c0a5fbbc | |||
| 7fdcae5d14 | |||
| d0e6893659 | |||
| 76cdd4c857 |
@@ -23,6 +23,13 @@ go test ./...
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
**M8 (Tier A) — Enforcement (window-minimize).** Drift finally costs something.
|
||||||
|
A planning-screen "Enforce focus" toggle arms the new `enforce.Guard` port: when
|
||||||
|
the drift judge confirms the active window is off-task, the daemon minimizes that
|
||||||
|
window (native X11, no `xdotool`). It is unprivileged, per-session (the chosen
|
||||||
|
enforcement level rides the snapshot), and degrades to today's advisory behavior
|
||||||
|
when off, unwired, or on a platform without the X11 adapter.
|
||||||
|
|
||||||
M7 (reflection): when a session ends, a fourth AI role — the reviewer —
|
M7 (reflection): when a session ends, a fourth AI role — the reviewer —
|
||||||
reflects on it, read against your recent sessions, and produces two short
|
reflects on it, read against your recent sessions, and produces two short
|
||||||
lines: a recap shown on the Review screen, and a carry-forward takeaway that
|
lines: a recap shown on the Review screen, and a carry-forward takeaway that
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
|
"antidrift/internal/enforce"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/knowledge"
|
"antidrift/internal/knowledge"
|
||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
@@ -72,6 +73,9 @@ func main() {
|
|||||||
log.Printf("status: writing %s", statusPath)
|
log.Printf("status: writing %s", statusPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctrl.SetGuard(enforce.NewGuard())
|
||||||
|
log.Printf("enforce: window-minimize guard")
|
||||||
|
|
||||||
src := evidence.NewSource()
|
src := evidence.NewSource()
|
||||||
go src.Watch(context.Background(), ctrl.RecordWindow)
|
go src.Watch(context.Background(), ctrl.RecordWindow)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
# 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."
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Package enforce makes drift cost something at the OS level. Tier A minimizes
|
||||||
|
// the active window when the session is enforcing and the drift judge has
|
||||||
|
// confirmed the window is off-task. The Guard is a pure OS primitive; all
|
||||||
|
// policy — whether and when to enforce — lives in the session controller.
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Guard performs OS-level enforcement actions on demand.
|
||||||
|
type Guard interface {
|
||||||
|
// MinimizeActive minimizes the currently-focused window. It is idempotent
|
||||||
|
// (minimizing an already-minimized window is harmless) and best-effort: it
|
||||||
|
// returns an error for diagnostics, but callers never block on it and treat
|
||||||
|
// failure as "enforcement did nothing this time."
|
||||||
|
MinimizeActive(ctx context.Context) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package enforce
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// NewGuard must return a usable Guard on every platform (real on linux, no-op
|
||||||
|
// elsewhere). We assert non-nil only: calling MinimizeActive here would touch a
|
||||||
|
// real X server on linux, which the integration test covers under a DISPLAY
|
||||||
|
// guard. The behavioural contract is exercised in the session package via a fake
|
||||||
|
// Guard.
|
||||||
|
func TestNewGuardReturnsUsableGuard(t *testing.T) {
|
||||||
|
if g := NewGuard(); g == nil {
|
||||||
|
t.Fatal("NewGuard returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// NewGuard returns a no-op guard on platforms without the X11 adapter.
|
||||||
|
func NewGuard() Guard { return noopGuard{} }
|
||||||
|
|
||||||
|
type noopGuard struct{}
|
||||||
|
|
||||||
|
func (noopGuard) MinimizeActive(context.Context) error { return nil }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jezek/xgbutil"
|
||||||
|
"github.com/jezek/xgbutil/ewmh"
|
||||||
|
"github.com/jezek/xgbutil/icccm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewGuard returns the real X11 window-minimize guard.
|
||||||
|
func NewGuard() Guard { return x11Guard{} }
|
||||||
|
|
||||||
|
type x11Guard struct{}
|
||||||
|
|
||||||
|
// MinimizeActive iconifies the currently-focused window by sending an ICCCM
|
||||||
|
// WM_CHANGE_STATE -> IconicState client message to the root window. It opens a
|
||||||
|
// short-lived X connection per call: enforcement fires at most once per drift
|
||||||
|
// observation (which is debounce-gated upstream), so there is no shared
|
||||||
|
// connection or event loop to manage, and nothing to race on shutdown. Any X
|
||||||
|
// failure is returned for the caller to log; the caller never blocks on it.
|
||||||
|
func (x11Guard) MinimizeActive(_ context.Context) error {
|
||||||
|
X, err := xgbutil.NewConn()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enforce: cannot connect to X server: %w", err)
|
||||||
|
}
|
||||||
|
defer X.Conn().Close()
|
||||||
|
|
||||||
|
active, err := ewmh.ActiveWindowGet(X)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enforce: no active window: %w", err)
|
||||||
|
}
|
||||||
|
if active == 0 {
|
||||||
|
return nil // nothing focused; nothing to minimize
|
||||||
|
}
|
||||||
|
if err := ewmh.ClientEvent(X, active, "WM_CHANGE_STATE", icccm.StateIconic); err != nil {
|
||||||
|
return fmt.Errorf("enforce: minimize request failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package enforce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestX11GuardMinimizeActiveDoesNotPanic(t *testing.T) {
|
||||||
|
if os.Getenv("DISPLAY") == "" {
|
||||||
|
t.Skip("no DISPLAY; skipping live X11 minimize smoke test")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// Either it minimizes the active window or returns an error (e.g. no active
|
||||||
|
// window); we only assert it returns without panicking.
|
||||||
|
if err := NewGuard().MinimizeActive(ctx); err != nil {
|
||||||
|
t.Logf("MinimizeActive returned (acceptable): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/domain"
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/enforce"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/knowledge"
|
"antidrift/internal/knowledge"
|
||||||
"antidrift/internal/statemachine"
|
"antidrift/internal/statemachine"
|
||||||
@@ -77,6 +78,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
driftDebounce = 10 * time.Second
|
driftDebounce = 10 * time.Second
|
||||||
driftTimeout = 30 * time.Second
|
driftTimeout = 30 * time.Second
|
||||||
|
enforceTimeout = 5 * time.Second
|
||||||
nudgeDebounce = 5 * time.Minute
|
nudgeDebounce = 5 * time.Minute
|
||||||
nudgeTimeout = 30 * time.Second
|
nudgeTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
@@ -148,6 +150,8 @@ type Controller struct {
|
|||||||
reflectionGen int
|
reflectionGen int
|
||||||
|
|
||||||
allowedClasses []string // durable: the active session's allowed window classes
|
allowedClasses []string // durable: the active session's allowed window classes
|
||||||
|
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
|
||||||
|
guard enforce.Guard
|
||||||
judge ai.DriftJudge
|
judge ai.DriftJudge
|
||||||
driftStatus string
|
driftStatus string
|
||||||
driftReason string
|
driftReason string
|
||||||
@@ -186,6 +190,7 @@ type DriftView struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Nudge string `json:"nudge,omitempty"`
|
Nudge string `json:"nudge,omitempty"`
|
||||||
|
Enforced bool `json:"enforced,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoachView struct {
|
type CoachView struct {
|
||||||
@@ -286,6 +291,7 @@ func New(snapshotPath string) (*Controller, error) {
|
|||||||
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
||||||
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
||||||
c.allowedClasses = s.AllowedWindowClasses
|
c.allowedClasses = s.AllowedWindowClasses
|
||||||
|
c.enforcementLevel = s.EnforcementLevel
|
||||||
// Drift state is not persisted: recompute fresh after restart to avoid
|
// Drift state is not persisted: recompute fresh after restart to avoid
|
||||||
// stale interrupts. This also initializes judgedClasses (the pipeline
|
// stale interrupts. This also initializes judgedClasses (the pipeline
|
||||||
// writes to it) so a restored Active session never panics on a nil map.
|
// writes to it) so a restored Active session never panics on a nil map.
|
||||||
@@ -405,7 +411,8 @@ func (c *Controller) stateLocked() State {
|
|||||||
if status == "" {
|
if status == "" {
|
||||||
status = driftIdle
|
status = driftIdle
|
||||||
}
|
}
|
||||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}
|
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
|
||||||
|
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -432,6 +439,7 @@ func (c *Controller) persistLocked() error {
|
|||||||
snap.SessionID = c.stats.SessionID
|
snap.SessionID = c.stats.SessionID
|
||||||
}
|
}
|
||||||
snap.AllowedWindowClasses = c.allowedClasses
|
snap.AllowedWindowClasses = c.allowedClasses
|
||||||
|
snap.EnforcementLevel = c.enforcementLevel
|
||||||
snap.ReflectionStatus = c.reflectionStatus
|
snap.ReflectionStatus = c.reflectionStatus
|
||||||
snap.ReflectionRecap = c.reflectionRecap
|
snap.ReflectionRecap = c.reflectionRecap
|
||||||
snap.CarryForward = c.carryForward
|
snap.CarryForward = c.carryForward
|
||||||
@@ -701,6 +709,14 @@ func (c *Controller) AllowedClassesForTest() []string {
|
|||||||
return append([]string(nil), c.allowedClasses...)
|
return append([]string(nil), c.allowedClasses...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
||||||
// working; unmatched windows simply stay idle.
|
// working; unmatched windows simply stay idle.
|
||||||
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
||||||
@@ -709,6 +725,14 @@ func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||||
// local matching and the drift judge are unaffected.
|
// local matching and the drift judge are unaffected.
|
||||||
func (c *Controller) SetNudge(n ai.Nudger) {
|
func (c *Controller) SetNudge(n ai.Nudger) {
|
||||||
@@ -867,7 +891,7 @@ func coachErrorMessage(err error) string {
|
|||||||
// StartManualCommitment validates input, activates a new commitment, mints a
|
// StartManualCommitment validates input, activates a new commitment, mints a
|
||||||
// session, seeds evidence stats from the latest window, and moves Planning ->
|
// session, seeds evidence stats from the latest window, and moves Planning ->
|
||||||
// Active.
|
// Active.
|
||||||
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error {
|
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
||||||
@@ -889,6 +913,7 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
|
|||||||
c.outcomePending = ""
|
c.outcomePending = ""
|
||||||
c.resetCoachLocked()
|
c.resetCoachLocked()
|
||||||
c.allowedClasses = append([]string(nil), allowedClasses...)
|
c.allowedClasses = append([]string(nil), allowedClasses...)
|
||||||
|
c.enforcementLevel = level
|
||||||
c.resetDriftLocked()
|
c.resetDriftLocked()
|
||||||
|
|
||||||
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
||||||
@@ -956,6 +981,7 @@ func (c *Controller) End() error {
|
|||||||
c.stats = nil
|
c.stats = nil
|
||||||
c.outcomePending = ""
|
c.outcomePending = ""
|
||||||
c.allowedClasses = nil
|
c.allowedClasses = nil
|
||||||
|
c.enforcementLevel = ""
|
||||||
c.resetDriftLocked()
|
c.resetDriftLocked()
|
||||||
return c.persistLocked()
|
return c.persistLocked()
|
||||||
}
|
}
|
||||||
@@ -1002,13 +1028,35 @@ func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
|||||||
c.applyEvent(now, snap)
|
c.applyEvent(now, snap)
|
||||||
c.recordTitleLocked(snap.Title)
|
c.recordTitleLocked(snap.Title)
|
||||||
launch := c.evaluateDriftLocked(now, snap)
|
launch := c.evaluateDriftLocked(now, snap)
|
||||||
|
enforceAct := c.enforceActionLocked()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if launch != nil {
|
if launch != nil {
|
||||||
go launch()
|
go launch()
|
||||||
}
|
}
|
||||||
|
if enforceAct != nil {
|
||||||
|
go enforceAct()
|
||||||
|
}
|
||||||
c.notify()
|
c.notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enforceActionLocked returns the minimize thunk when this observation should be
|
||||||
|
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
|
||||||
|
// returned func performs blocking X11 I/O and MUST run after the caller releases
|
||||||
|
// c.mu, following the off-lock async-I/O discipline used by the other roles.
|
||||||
|
func (c *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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
||||||
// and updates synchronous drift state. When an async judgment is warranted it
|
// and updates synchronous drift state. When an async judgment is warranted it
|
||||||
// returns the judging closure; the caller runs it in a goroutine after
|
// returns the judging closure; the caller runs it in a goroutine after
|
||||||
@@ -1083,10 +1131,15 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.judgedClasses[class] = v
|
c.judgedClasses[class] = v
|
||||||
|
var enforceAct func()
|
||||||
if c.stats != nil && c.stats.Current.Class == class {
|
if c.stats != nil && c.stats.Current.Class == class {
|
||||||
c.applyVerdictLocked(v)
|
c.applyVerdictLocked(v)
|
||||||
|
enforceAct = c.enforceActionLocked()
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
if enforceAct != nil {
|
||||||
|
enforceAct()
|
||||||
|
}
|
||||||
c.notify()
|
c.notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
|||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("enter planning: %v", err)
|
t.Fatalf("enter planning: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil {
|
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start commitment: %v", err)
|
t.Fatalf("start commitment: %v", err)
|
||||||
}
|
}
|
||||||
st := c.State()
|
st := c.State()
|
||||||
@@ -67,7 +67,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
|||||||
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil {
|
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil, domain.EnforcementWarn); err == nil {
|
||||||
t.Fatalf("empty next action should error")
|
t.Fatalf("empty next action should error")
|
||||||
}
|
}
|
||||||
if c.State().RuntimeState != domain.RuntimePlanning {
|
if c.State().RuntimeState != domain.RuntimePlanning {
|
||||||
@@ -82,7 +82,7 @@ func TestStateRestoresFromSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
|
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
|
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,7 +104,7 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
|
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
want := first.State().Commitment.DeadlineUnixSecs
|
want := first.State().Commitment.DeadlineUnixSecs
|
||||||
|
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
@@ -147,7 +147,7 @@ func TestAccumulatesBucketsAndSwitches(t *testing.T) {
|
|||||||
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
||||||
|
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn) // seeds at t=1000 with code/antidrift
|
||||||
|
|
||||||
clk.advance(10 * time.Second)
|
clk.advance(10 * time.Second)
|
||||||
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
||||||
@@ -182,7 +182,7 @@ func TestUnavailableAccruesToReservedBucket(t *testing.T) {
|
|||||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
c.SetClock(clk.fn())
|
c.SetClock(clk.fn())
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn) // seed: empty unavailable latestWindow
|
||||||
// latestWindow was zero-value (unavailable) at seed time.
|
// latestWindow was zero-value (unavailable) at seed time.
|
||||||
clk.advance(5 * time.Second)
|
clk.advance(5 * time.Second)
|
||||||
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
||||||
@@ -199,7 +199,7 @@ func TestCrashReplayRebuildsStats(t *testing.T) {
|
|||||||
first.SetClock(clk.fn())
|
first.SetClock(clk.fn())
|
||||||
first.RecordWindow(snap("code", "antidrift"))
|
first.RecordWindow(snap("code", "antidrift"))
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
|
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
clk.advance(15 * time.Second)
|
clk.advance(15 * time.Second)
|
||||||
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ func TestEndWritesAuditSummary(t *testing.T) {
|
|||||||
c.SetClock(clk.fn())
|
c.SetClock(clk.fn())
|
||||||
c.RecordWindow(snap("code", "antidrift"))
|
c.RecordWindow(snap("code", "antidrift"))
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil)
|
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
clk.advance(30 * time.Second)
|
clk.advance(30 * time.Second)
|
||||||
c.RecordWindow(snap("firefox", "docs"))
|
c.RecordWindow(snap("firefox", "docs"))
|
||||||
clk.advance(10 * time.Second)
|
clk.advance(10 * time.Second)
|
||||||
@@ -374,7 +374,7 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
@@ -389,7 +389,7 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
|||||||
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
|
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn)
|
||||||
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
||||||
if err := c.OnTask(); err != nil {
|
if err := c.OnTask(); err != nil {
|
||||||
t.Fatalf("ontask: %v", err)
|
t.Fatalf("ontask: %v", err)
|
||||||
@@ -434,11 +434,16 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startActive(t *testing.T, c *Controller, allowed []string) {
|
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()
|
t.Helper()
|
||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("planning: %v", err)
|
t.Fatalf("planning: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil {
|
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed, level); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,7 +546,7 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
|||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
_ = first.EnterPlanning()
|
_ = first.EnterPlanning()
|
||||||
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}); err != nil {
|
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
second, err := New(path)
|
second, err := New(path)
|
||||||
@@ -553,13 +558,98 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
|
|||||||
waitDriftStatus(t, second, "drifting")
|
waitDriftStatus(t, second, "drifting")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeGuard struct{ calls int32 }
|
||||||
|
|
||||||
|
func (g *fakeGuard) MinimizeActive(context.Context) error {
|
||||||
|
atomic.AddInt32(&g.calls, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitGuardCalls(t *testing.T, g *fakeGuard, min int32) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if atomic.LoadInt32(&g.calls) >= min {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("guard minimize calls never reached %d (got %d)", min, atomic.LoadInt32(&g.calls))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriftMinimizesAtBlockViaBothPaths(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
g := &fakeGuard{}
|
||||||
|
c.SetGuard(g)
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||||
|
|
||||||
|
// Async path: first off-task observation launches the judge; on confirmation
|
||||||
|
// the guard minimizes once.
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := waitDriftStatus(t, c, "drifting")
|
||||||
|
if !st.Drift.Enforced {
|
||||||
|
t.Fatalf("Enforced should be true while drifting at block: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
waitGuardCalls(t, g, 1)
|
||||||
|
|
||||||
|
// Synchronous cached path: same class again hits the per-class cache, sets
|
||||||
|
// drifting under the lock, and minimizes again without a new judgment.
|
||||||
|
c.RecordWindow(obs("firefox", "Reddit"))
|
||||||
|
waitGuardCalls(t, g, 2)
|
||||||
|
if got := atomic.LoadInt32(&g.calls); got < 2 {
|
||||||
|
t.Fatalf("cached-drift path did not minimize: %d calls", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarnLevelNeverMinimizes(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
g := &fakeGuard{}
|
||||||
|
c.SetGuard(g)
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementWarn)
|
||||||
|
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
st := waitDriftStatus(t, c, "drifting")
|
||||||
|
if st.Drift.Enforced {
|
||||||
|
t.Fatalf("Enforced must be false at warn: %+v", st.Drift)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond) // allow any stray enforcement goroutine to run
|
||||||
|
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||||
|
t.Fatalf("warn level must not minimize, got %d calls", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnTaskNeverMinimizes(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}})
|
||||||
|
g := &fakeGuard{}
|
||||||
|
c.SetGuard(g)
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||||
|
|
||||||
|
c.RecordWindow(obs("code", "main.go")) // local on-task match
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := atomic.LoadInt32(&g.calls); got != 0 {
|
||||||
|
t.Fatalf("on-task must not minimize, got %d calls", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWithoutGuardDoesNotPanic(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||||
|
// No guard wired.
|
||||||
|
startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock)
|
||||||
|
c.RecordWindow(obs("firefox", "YouTube"))
|
||||||
|
waitDriftStatus(t, c, "drifting") // must reach drifting without panicking
|
||||||
|
}
|
||||||
|
|
||||||
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||||
c, _ := newTestController(t)
|
c, _ := newTestController(t)
|
||||||
_ = c.EnterPlanning()
|
_ = c.EnterPlanning()
|
||||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
||||||
_ = c.RequestCoach("x")
|
_ = c.RequestCoach("x")
|
||||||
waitCoachStatus(t, c, "ready")
|
waitCoachStatus(t, c, "ready")
|
||||||
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
|
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
if c.State().Coach != nil {
|
if c.State().Coach != nil {
|
||||||
@@ -888,7 +978,7 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
|||||||
waitTasksStatus(t, c, "pending")
|
waitTasksStatus(t, c, "pending")
|
||||||
|
|
||||||
// Step 2: leave planning via a commitment — the stale fetch is still blocked.
|
// Step 2: leave planning via a commitment — the stale fetch is still blocked.
|
||||||
if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil); err != nil {
|
if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start commitment: %v", err)
|
t.Fatalf("start commitment: %v", err)
|
||||||
}
|
}
|
||||||
if c.State().RuntimeState != domain.RuntimeActive {
|
if c.State().RuntimeState != domain.RuntimeActive {
|
||||||
@@ -1066,7 +1156,7 @@ func driveToReview(t *testing.T, c *Controller) {
|
|||||||
if err := c.EnterPlanning(); err != nil {
|
if err := c.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("planning: %v", err)
|
t.Fatalf("planning: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil); err != nil {
|
if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.Complete(); err != nil {
|
if err := c.Complete(); err != nil {
|
||||||
@@ -1158,7 +1248,7 @@ func TestCarryForwardSurvivesRestart(t *testing.T) {
|
|||||||
if err := first.EnterPlanning(); err != nil {
|
if err := first.EnterPlanning(); err != nil {
|
||||||
t.Fatalf("planning: %v", err)
|
t.Fatalf("planning: %v", err)
|
||||||
}
|
}
|
||||||
if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
if err := first.Complete(); err != nil {
|
if err := first.Complete(); err != nil {
|
||||||
@@ -1181,3 +1271,27 @@ func TestCarryForwardSurvivesRestart(t *testing.T) {
|
|||||||
t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection)
|
t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnforcementLevelPersists(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first.SetClock(func() time.Time { return time.Unix(1000, 0) })
|
||||||
|
if err := first.EnterPlanning(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementBlock); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := second.EnforcementLevelForTest(); got != domain.EnforcementBlock {
|
||||||
|
t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type Snapshot struct {
|
|||||||
ReflectionStatus string `json:"reflection_status,omitempty"`
|
ReflectionStatus string `json:"reflection_status,omitempty"`
|
||||||
ReflectionRecap string `json:"reflection_recap,omitempty"`
|
ReflectionRecap string `json:"reflection_recap,omitempty"`
|
||||||
CarryForward string `json:"carry_forward,omitempty"`
|
CarryForward string `json:"carry_forward,omitempty"`
|
||||||
|
|
||||||
|
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPath returns ~/.antidrift/state.json.
|
// DefaultPath returns ~/.antidrift/state.json.
|
||||||
|
|||||||
@@ -124,3 +124,7 @@ input:focus { outline: 0; border-color: var(--accent); }
|
|||||||
background: none; border: none; padding: 0; font: inherit; font-size: 12px;
|
background: none; border: none; padding: 0; font: inherit; font-size: 12px;
|
||||||
color: var(--accent); cursor: pointer; text-decoration: underline;
|
color: var(--accent); cursor: pointer; text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function updateActiveDrift(state) {
|
|||||||
el.className = 'band statusband col';
|
el.className = 'band statusband col';
|
||||||
el.innerHTML = `<div><span class="pill">Drift</span></div>
|
el.innerHTML = `<div><span class="pill">Drift</span></div>
|
||||||
<div class="drift-reason">${drift.reason || 'This looks off task.'}</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">
|
<div class="band-actions">
|
||||||
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
|
<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="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||||
@@ -242,6 +243,8 @@ function render(state) {
|
|||||||
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
||||||
<label>Allowed apps (comma-separated window classes)</label>
|
<label>Allowed apps (comma-separated window classes)</label>
|
||||||
<input id="apps" placeholder="e.g. code, firefox">
|
<input id="apps" placeholder="e.g. code, firefox">
|
||||||
|
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
|
||||||
|
<p class="hint">Minimize off-task windows when you drift.</p>
|
||||||
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||||
@@ -254,6 +257,7 @@ function render(state) {
|
|||||||
timebox_secs: Math.round(+mins.value * 60),
|
timebox_secs: Math.round(+mins.value * 60),
|
||||||
allowed_window_classes: (document.getElementById('apps').value || '')
|
allowed_window_classes: (document.getElementById('apps').value || '')
|
||||||
.split(',').map(s => s.trim()).filter(Boolean),
|
.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
enforce: document.getElementById('enforce').checked,
|
||||||
});
|
});
|
||||||
document.getElementById('sharpen').onclick = () => {
|
document.getElementById('sharpen').onclick = () => {
|
||||||
const intent = document.getElementById('intent').value.trim();
|
const intent = document.getElementById('intent').value.trim();
|
||||||
|
|||||||
+7
-1
@@ -12,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
"antidrift/internal/statemachine"
|
"antidrift/internal/statemachine"
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@ type commitmentRequest struct {
|
|||||||
SuccessCondition string `json:"success_condition"`
|
SuccessCondition string `json:"success_condition"`
|
||||||
TimeboxSecs int64 `json:"timebox_secs"`
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||||||
|
Enforce bool `json:"enforce"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCommitment(c *gin.Context) {
|
func (s *Server) handleCommitment(c *gin.Context) {
|
||||||
@@ -118,7 +120,11 @@ func (s *Server) handleCommitment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses)
|
level := domain.EnforcementWarn
|
||||||
|
if req.Enforce {
|
||||||
|
level = domain.EnforcementBlock
|
||||||
|
}
|
||||||
|
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.armExpiry()
|
s.armExpiry()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,6 +362,37 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s := newTestServer(t)
|
||||||
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
||||||
|
|||||||
Reference in New Issue
Block a user