Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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— theGuardinterface (no build tag). One responsibility: define the port.internal/enforce/guard_other.go(//go:build !linux) — no-opNewGuard.internal/enforce/x11.go(//go:build linux) — realNewGuardusing 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 withoutDISPLAY.
Modified files
internal/store/store.go—SnapshotgainsEnforcementLevel.internal/session/session.go—EnforcementLevelfield, signature change, persistence, theenforcehook,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):
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(enforceimport,guardfield,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 enforce — enforce 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."