Files
antidrift/docs/superpowers/plans/2026-06-02-windows-support.md
felixm 885b0f9224 Plan Windows 11 support implementation
Six TDD/cross-compile tasks: pure class normalization and emit-on-change
tracker (unit-tested on Linux), Win32 binding, the two windows-tagged port
adapters, build-tag narrowing, and a whole-module cross-compile gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:08:01 -04:00

18 KiB
Raw Permalink Blame History

Windows 11 Support 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: Make AntiDrift function end-to-end on Windows 11 by adding //go:build windows adapters behind the existing evidence.Source and enforce.Guard ports — active-window sensing (polling) and window-minimize enforcement — with no consumer code changes.

Architecture: Two new windows-tagged adapters call a small shared Win32 binding (internal/winapi). The syscall-free logic (process-path → class normalization, emit-on-change tracking) is extracted into untagged files so it is unit-tested on Linux via TDD. The syscall-bound code is verified by cross-compilation only (no Windows machine is available). The current //go:build !linux no-op fallbacks are narrowed to //go:build !linux && !windows so Windows links the real adapters while macOS/other stay no-ops.

Tech Stack: Go 1.26, golang.org/x/sys/windows (already an indirect dep; promoted to direct), pure Go (no cgo). Win32 calls: GetForegroundWindow, GetWindowThreadProcessId, OpenProcess/QueryFullProcessImageName/CloseHandle (all typed in x/sys/windows), plus GetWindowTextW and ShowWindow via windows.NewLazySystemDLL("user32.dll").

Reference spec: docs/superpowers/specs/2026-06-02-windows-support-design.md


File structure

  • Create internal/winapi/class.go — untagged — ClassFromImagePath (pure: image path → class string). Builds on all platforms; this is the only file that makes package winapi compile on Linux.
  • Create internal/winapi/class_test.go — untagged — table test for ClassFromImagePath (runs on Linux).
  • Create internal/winapi/winapi.go//go:build windowsForegroundWindow() and MinimizeForeground() over the Win32 calls.
  • Create internal/evidence/foreground_tracker.go — untagged — foregroundTracker.changed (pure emit-on-change predicate). Runs on Linux.
  • Create internal/evidence/foreground_tracker_test.go — untagged — tests for the tracker.
  • Create internal/evidence/windows.go//go:build windows — polling Source (NewSource, windowsSource.Watch).
  • Create internal/enforce/windows.go//go:build windowsGuard (NewGuard, windowsGuard.MinimizeActive).
  • Modify internal/evidence/source_other.go:1 — build tag //go:build !linux//go:build !linux && !windows.
  • Modify internal/enforce/guard_other.go:1 — build tag //go:build !linux//go:build !linux && !windows.
  • Modify go.modgo mod tidy promotes golang.org/x/sys from indirect to direct.

No changes to session, web, domain, or cmd — they already consume the ports and degrade on Health.


Task 1: Pure class-name normalization (TDD on Linux)

ClassFromImagePath turns a Windows process image path into the on-task class identity: base file name, trailing .exe removed (case-insensitive), lowercased. It must be correct when the test runs on Linux, so it parses separators itself rather than using OS-dependent path/filepath.

Files:

  • Create: internal/winapi/class.go

  • Test: internal/winapi/class_test.go

  • Step 1: Write the failing test

Create internal/winapi/class_test.go:

package winapi

import "testing"

func TestClassFromImagePath(t *testing.T) {
	cases := []struct{ in, want string }{
		{`C:\Program Files\Microsoft VS Code\Code.exe`, "code"},
		{`C:\Windows\explorer.exe`, "explorer"},
		{`chrome.exe`, "chrome"},
		{`C:\x\FOO.EXE`, "foo"},
		{`C:\x\My.App.exe`, "my.app"},
		{`C:/forward/slash/Code.exe`, "code"},
		{`firefox`, "firefox"},
		{``, ""},
	}
	for _, c := range cases {
		if got := ClassFromImagePath(c.in); got != c.want {
			t.Errorf("ClassFromImagePath(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/winapi/ Expected: FAIL — build error, undefined: ClassFromImagePath.

  • Step 3: Write minimal implementation

Create internal/winapi/class.go:

// Package winapi is the Windows Win32 binding layer for AntiDrift's OS ports.
// The syscall-bound code lives in windows-tagged files; this untagged file
// holds the pure path logic so it builds and is tested on every platform.
package winapi

import "strings"

// ClassFromImagePath derives the on-task class identity from a process image
// path: the base file name with any trailing ".exe" removed (case-insensitive),
// lowercased. It is the Windows analog of an X11 WM_CLASS. It parses both `\`
// and `/` separators so it is correct regardless of the host OS running the
// test. Returns "" for an empty path.
func ClassFromImagePath(p string) string {
	if i := strings.LastIndexAny(p, `\/`); i >= 0 {
		p = p[i+1:]
	}
	if len(p) >= 4 && strings.EqualFold(p[len(p)-4:], ".exe") {
		p = p[:len(p)-4]
	}
	return strings.ToLower(p)
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/winapi/ Expected: PASS (ok antidrift/internal/winapi).

  • Step 5: Commit
git add internal/winapi/class.go internal/winapi/class_test.go
git commit -m "Add pure process-path to class normalization for Windows"

Task 2: Emit-on-change tracker (TDD on Linux)

The X11 source is event-driven; the Windows source polls. foregroundTracker reproduces "emit only on change" for the poll loop: it remembers the last observation and reports whether the new one differs. It tracks availability too, so a steady "no foreground window" run does not re-emit every tick.

Files:

  • Create: internal/evidence/foreground_tracker.go

  • Test: internal/evidence/foreground_tracker_test.go

  • Step 1: Write the failing test

Create internal/evidence/foreground_tracker_test.go:

package evidence

import "testing"

func TestForegroundTrackerChanged(t *testing.T) {
	var tr foregroundTracker

	if !tr.changed(true, 100, "A") {
		t.Fatal("first observation should always report changed")
	}
	if tr.changed(true, 100, "A") {
		t.Error("identical observation should not report changed")
	}
	if !tr.changed(true, 100, "B") {
		t.Error("title change on same hwnd should report changed")
	}
	if !tr.changed(true, 200, "B") {
		t.Error("hwnd change should report changed")
	}
	if !tr.changed(false, 0, "") {
		t.Error("transition to unavailable should report changed")
	}
	if tr.changed(false, 0, "") {
		t.Error("repeated unavailable should not report changed")
	}
	if !tr.changed(true, 200, "B") {
		t.Error("transition back to available should report changed")
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/evidence/ -run TestForegroundTracker Expected: FAIL — build error, undefined: foregroundTracker.

  • Step 3: Write minimal implementation

Create internal/evidence/foreground_tracker.go:

package evidence

// foregroundTracker remembers the last observed foreground window so the
// Windows polling Source emits only on change. hwnd is held as uintptr so this
// file stays platform-neutral (it must build and test on Linux).
type foregroundTracker struct {
	primed    bool
	available bool
	hwnd      uintptr
	title     string
}

// changed reports whether (available, hwnd, title) differs from the last
// observation and records the new values. The first call always returns true.
func (t *foregroundTracker) changed(available bool, hwnd uintptr, title string) bool {
	if t.primed && available == t.available && hwnd == t.hwnd && title == t.title {
		return false
	}
	t.primed, t.available, t.hwnd, t.title = true, available, hwnd, title
	return true
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/evidence/ -run TestForegroundTracker Expected: PASS.

  • Step 5: Commit
git add internal/evidence/foreground_tracker.go internal/evidence/foreground_tracker_test.go
git commit -m "Add emit-on-change tracker for the Windows polling sensor"

Task 3: Win32 binding layer (cross-compile verified)

The syscall surface. Cannot be unit-tested without Windows; verified by cross-compilation. All referenced x/sys/windows symbols were confirmed present via GOOS=windows go doc while writing the spec.

Files:

  • Create: internal/winapi/winapi.go

  • Modify: go.mod (via go mod tidy)

  • Step 1: Write the implementation

Create internal/winapi/winapi.go:

//go:build windows

package winapi

import (
	"unsafe"

	"golang.org/x/sys/windows"
)

var (
	user32             = windows.NewLazySystemDLL("user32.dll")
	procGetWindowTextW = user32.NewProc("GetWindowTextW")
	procShowWindow     = user32.NewProc("ShowWindow")
)

// ForegroundWindow returns the current foreground window's handle (as a uintptr
// so platform-neutral callers need not import windows), its title, and its
// on-task class (process exe base name; see ClassFromImagePath). ok is false
// when there is no foreground window (e.g. secure desktop / lock screen).
func ForegroundWindow() (hwnd uintptr, title, class string, ok bool) {
	h := windows.GetForegroundWindow()
	if h == 0 {
		return 0, "", "", false
	}
	return uintptr(h), windowTitle(h), windowClass(h), true
}

func windowTitle(h windows.HWND) string {
	const max = 512
	buf := make([]uint16, max)
	n, _, _ := procGetWindowTextW.Call(
		uintptr(h),
		uintptr(unsafe.Pointer(&buf[0])),
		uintptr(max),
	)
	return windows.UTF16ToString(buf[:n])
}

func windowClass(h windows.HWND) string {
	var pid uint32
	if _, err := windows.GetWindowThreadProcessId(h, &pid); err != nil || pid == 0 {
		return ""
	}
	proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
	if err != nil {
		return ""
	}
	defer windows.CloseHandle(proc)

	buf := make([]uint16, windows.MAX_PATH)
	size := uint32(len(buf))
	if err := windows.QueryFullProcessImageName(proc, 0, &buf[0], &size); err != nil {
		return ""
	}
	return ClassFromImagePath(windows.UTF16ToString(buf[:size]))
}

// MinimizeForeground minimizes the current foreground window. It returns nil
// when nothing is focused. ShowWindow's BOOL return reports prior visibility,
// not success, so there is nothing to error-check; minimize is best-effort by
// the Guard contract.
func MinimizeForeground() error {
	h := windows.GetForegroundWindow()
	if h == 0 {
		return nil
	}
	procShowWindow.Call(uintptr(h), uintptr(windows.SW_MINIMIZE))
	return nil
}
  • Step 2: Promote the dependency and verify the package cross-compiles

Run:

go mod tidy
GOOS=windows GOARCH=amd64 go build ./internal/winapi/

Expected: both succeed with no output. go.mod now lists golang.org/x/sys in the direct (non-// indirect) require block.

  • Step 3: Verify the package still builds on Linux (pure file only)

Run: go test ./internal/winapi/ Expected: PASS — on Linux the package is just class.go + its test; winapi.go is excluded by its build tag.

  • Step 4: Commit
git add internal/winapi/winapi.go go.mod go.sum
git commit -m "Add Win32 binding for foreground window and minimize"

Task 4: Windows evidence.Source adapter (cross-compile verified)

The polling sensor. Emits one snapshot immediately, then on every 750ms tick emits only when the foreground window or title changed (via foregroundTracker from Task 2 and winapi.ForegroundWindow from Task 3). Also narrows the non-Linux fallback so it no longer claims Windows.

Files:

  • Create: internal/evidence/windows.go

  • Modify: internal/evidence/source_other.go:1

  • Step 1: Narrow the fallback build tag

In internal/evidence/source_other.go, change the first line:

//go:build !linux && !windows

(from //go:build !linux). Leave the rest of the file unchanged.

  • Step 2: Write the Windows source

Create internal/evidence/windows.go:

//go:build windows

package evidence

import (
	"context"
	"time"

	"antidrift/internal/winapi"
)

// pollInterval is how often the Windows sensor samples the foreground window.
// ~1s latency on a window switch is immaterial for a focus tracker, and polling
// avoids the message-loop/callback machinery a SetWinEventHook source needs.
const pollInterval = 750 * time.Millisecond

// NewSource returns the Windows active-window sensor (polling).
func NewSource() Source { return windowsSource{} }

type windowsSource struct{}

// Watch emits the current window immediately, then samples every pollInterval,
// emitting only when the foreground window or its title changes. A read with no
// foreground window yields an Unavailable snapshot (once, until it recovers). It
// runs until ctx is cancelled. It never panics the daemon.
func (windowsSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
	var tr foregroundTracker
	poll := func() {
		hwnd, title, class, ok := winapi.ForegroundWindow()
		if !tr.changed(ok, hwnd, title) {
			return
		}
		if !ok {
			onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no foreground window"}})
			return
		}
		onChange(WindowSnapshot{Title: title, Class: class, Health: EvidenceHealth{Available: true}})
	}

	poll() // immediate current window
	ticker := time.NewTicker(pollInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			poll()
		}
	}
}
  • Step 3: Verify Windows build and Linux tests

Run:

GOOS=windows GOARCH=amd64 go build ./internal/evidence/
go test ./internal/evidence/

Expected: the Windows build succeeds with no output; the Linux test run passes (tracker test included, X11/other files unaffected).

  • Step 4: Verify the fallback world is intact (macOS still no-op)

Run: GOOS=darwin GOARCH=amd64 go build ./internal/evidence/ Expected: succeeds — source_other.go (!linux && !windows) still provides NewSource on darwin.

  • Step 5: Commit
git add internal/evidence/windows.go internal/evidence/source_other.go
git commit -m "Add Windows polling active-window sensor"

Task 5: Windows enforce.Guard adapter (cross-compile verified)

The minimize guard. Thin wrapper over winapi.MinimizeForeground. Narrows the non-Linux fallback the same way as Task 4.

Files:

  • Create: internal/enforce/windows.go

  • Modify: internal/enforce/guard_other.go:1

  • Step 1: Narrow the fallback build tag

In internal/enforce/guard_other.go, change the first line:

//go:build !linux && !windows

(from //go:build !linux). Leave the rest of the file unchanged.

  • Step 2: Write the Windows guard

Create internal/enforce/windows.go:

//go:build windows

package enforce

import (
	"context"

	"antidrift/internal/winapi"
)

// NewGuard returns the Windows window-minimize guard.
func NewGuard() Guard { return windowsGuard{} }

type windowsGuard struct{}

// MinimizeActive minimizes the current foreground window. It is best-effort:
// with nothing focused it does nothing and returns nil. Stateless and per-call,
// mirroring the short-lived X11 connection model.
func (windowsGuard) MinimizeActive(context.Context) error {
	return winapi.MinimizeForeground()
}
  • Step 3: Verify Windows build and the fallback world

Run:

GOOS=windows GOARCH=amd64 go build ./internal/enforce/
GOOS=darwin GOARCH=amd64 go build ./internal/enforce/
go build ./internal/enforce/

Expected: all three succeed with no output (Windows uses windows.go; darwin and Linux-host build paths still resolve NewGuard).

  • Step 4: Commit
git add internal/enforce/windows.go internal/enforce/guard_other.go
git commit -m "Add Windows window-minimize guard"

Task 6: Full cross-compilation gate and regression check

The primary automated guarantee for the syscall-bound code: the whole module cross-compiles for Windows, the fallback world is intact for macOS, and the Linux host build and full test suite are still green.

Files: none (verification only).

  • Step 1: Whole-module Windows cross-compile

Run: GOOS=windows GOARCH=amd64 go build ./... Expected: succeeds with no output. (This is the headline check: cmd/antidriftd, evidence, enforce, winapi, and every consumer link for Windows.)

  • Step 2: Whole-module macOS cross-compile (fallback intact)

Run: GOOS=darwin GOARCH=amd64 go build ./... Expected: succeeds with no output — proves the !linux && !windows tag edits did not orphan NewSource/NewGuard on other platforms.

  • Step 3: Linux host build and full test suite

Run:

go build ./...
go test ./...

Expected: build succeeds; all tests pass, including the new winapi and evidence pure-logic tests, with the existing Linux X11 integration tests unaffected.

  • Step 4: Confirm the Windows binary actually links (optional sanity)

Run: GOOS=windows GOARCH=amd64 go build -o /tmp/antidriftd.exe ./cmd/antidriftd && ls -l /tmp/antidriftd.exe Expected: a .exe is produced.

  • Step 5: Commit (if go mod tidy left any tidy changes)
git add -A
git commit -m "Verify Windows cross-compile and Linux regression for Windows support" --allow-empty

Deferred manual verification (when a Windows 11 machine is available)

Not part of this plan's automated gates — recorded for whoever runs it on real hardware later (from the spec):

  • Run antidriftd; confirm the browser opens and the live view shows the current window title and class.
  • Switch windows and change a browser tab; confirm snapshots update and health reads available.
  • Start a commitment with "Enforce focus" armed; focus an off-task window; confirm the drift judge fires and the window minimizes.

Self-review notes

  • Spec coverage: both ports (Task 4 sensor, Task 5 guard); winapi binding (Task 3); build-tag narrowing (Tasks 45); go mod tidy promotion (Task 3); pure-logic TDD for class + change-detection (Tasks 12); cross-compile + darwin-fallback + Linux-regression gates (Task 6); deferred manual steps recorded. No fabricated syscall mocks. The Class = exe-base-name decision is implemented in Task 1.
  • Type consistency: ClassFromImagePath (Tasks 1, 3); foregroundTracker.changed(available, hwnd, title) with hwnd uintptr (Tasks 2, 4); winapi.ForegroundWindow() (uintptr, string, string, bool) and winapi.MinimizeForeground() error (Tasks 3, 4, 5). Module path antidrift confirmed against go.mod.