Files
antidrift/docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md
T
2026-06-01 12:22:59 -04:00

27 KiB

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.goSnapshot gains EnforcementLevel.
  • internal/session/session.goEnforcementLevel field, signature change, persistence, the enforce hook, DriftView.Enforced.
  • internal/session/session_test.go — call-site updates, fakeGuard, enforcement tests.
  • internal/web/web.gocommitmentRequest.Enforce → level mapping.
  • internal/web/web_test.gostubJudge + enforced-on-the-wire test.
  • cmd/antidriftd/main.goctrl.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):

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:

// 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: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: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: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
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):

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):

	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):

	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:

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:

	c.enforcementLevel = level
  • Step 6: Persist and restore the level

In persistLocked, after snap.AllowedWindowClasses = c.allowedClasses, add:

	snap.EnforcementLevel = c.enforcementLevel

In New, inside the if c.runtimeState == domain.RuntimeActive && s.SessionID != "" block, right after c.allowedClasses = s.AllowedWindowClasses, add:

		c.enforcementLevel = s.EnforcementLevel
  • Step 7: Add the test accessor

In internal/session/session.go, near the other *ForTest helpers (e.g. AllowedClassesForTest), add:

// 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:

	Enforce bool `json:"enforce"`

and in handleCommitment, replace the StartManualCommitment call with a mapped level (Tier A honors only block/warn):

	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:

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
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):

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:

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:

	"antidrift/internal/enforce"

and add to the Controller struct (next to judge ai.DriftJudge):

	guard enforce.Guard
  • Step 4: Add the timeout constant and SetGuard

Add enforceTimeout beside driftTimeout in the const block:

	enforceTimeout = 5 * time.Second

Add the setter near SetDriftJudge:

// 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:

// 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:

	launch := c.evaluateDriftLocked(now, snap)
	c.mu.Unlock()
	if launch != nil {
		go launch()
	}
	c.notify()

with:

	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 enforceenforce is the imported package.)

  • Step 7: Wire the async path in the judge closure

In evaluateDriftLocked, the judge closure currently ends:

		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):

		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:

	Enforced bool `json:"enforced,omitempty"`

and in stateLocked, where the RuntimeActive drift projection is built, replace:

		st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage}

with:

		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
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):

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:

	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
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:

        <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:

    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:

    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:

.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:

**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
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."