From d0e68936599c5575a5a3d128f9637261ccf6d23f Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 12:22:59 -0400 Subject: [PATCH] Add M8 Tier A window-minimize enforcement implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-01-m8-enforcement-window-minimize.md | 796 ++++++++++++++++++ 1 file changed, 796 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md diff --git a/docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md b/docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md new file mode 100644 index 0000000..3afd95a --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md @@ -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 `
` (the one with Next action / Success condition / Minutes / Allowed apps), add the toggle right before the `Start commitment` button: + +```html + +

Minimize off-task windows when you drift.

+``` + +- [ ] **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 = `
Drift
+
${drift.reason || 'This looks off task.'}
+ ${drift.enforced ? '
Off-task window minimized.
' : ''} +
+ + + +
`; +``` + +- [ ] **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."