sketch: add patch callback hook to warm codereview cache

When the agent patches a file, concurrently pre-compile test binaries
in the background to speed up future codereview runs.

This helps make codereview runs faster without
pre-flighting everything in the whole repository.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s2a01805b644342f9k
diff --git a/claudetool/codereview/codereview.go b/claudetool/codereview/codereview.go
index d99edff..411195a 100644
--- a/claudetool/codereview/codereview.go
+++ b/claudetool/codereview/codereview.go
@@ -10,6 +10,7 @@
 	"path/filepath"
 	"slices"
 	"strings"
+	"sync"
 
 	"sketch.dev/claudetool"
 )
@@ -22,8 +23,11 @@
 	reviewed        []string     // history of all commits which have been reviewed
 	initialWorktree string       // git worktree at initial commit, absolute path
 	// "Related files" caching
-	processedChangedFileSets map[string]bool   // hash of sorted changedFiles -> processed
-	reportedRelatedFiles     map[string]bool   // file path -> reported
+	processedChangedFileSets map[string]bool // hash of sorted changedFiles -> processed
+	reportedRelatedFiles     map[string]bool // file path -> reported
+	// Pre-warming of Go build/test cache
+	warmMutex      sync.Mutex      // protects warmedPackages map
+	warmedPackages map[string]bool // packages that have been cache warmed
 }
 
 func NewCodeReviewer(ctx context.Context, repoRoot, sketchBaseRef string) (*CodeReviewer, error) {
@@ -32,6 +36,7 @@
 		sketchBaseRef:            sketchBaseRef,
 		processedChangedFileSets: make(map[string]bool),
 		reportedRelatedFiles:     make(map[string]bool),
+		warmedPackages:           make(map[string]bool),
 	}
 	if r.repoRoot == "" {
 		return nil, fmt.Errorf("NewCodeReviewer: repoRoot must be non-empty")
@@ -250,6 +255,9 @@
 }
 
 func (r *CodeReviewer) absPath(relPath string) string {
+	if filepath.IsAbs(relPath) {
+		return relPath
+	}
 	return filepath.Clean(filepath.Join(r.repoRoot, relPath))
 }
 
diff --git a/claudetool/codereview/differential.go b/claudetool/codereview/differential.go
index 77dde85..764f000 100644
--- a/claudetool/codereview/differential.go
+++ b/claudetool/codereview/differential.go
@@ -1188,3 +1188,78 @@
 	}
 	return false
 }
+
+// WarmTestCache runs 'go test -c' on relevant packages in the background
+// to warm up the Go build cache. This is intended to be called after patch
+// operations to prepare for future differential testing.
+// It uses the base commit (before state) to warm cache for packages that
+// will likely be tested during code review.
+func (r *CodeReviewer) WarmTestCache(modifiedFile string) {
+	if !r.isGoRepository() {
+		return
+	}
+	if !strings.HasSuffix(modifiedFile, ".go") {
+		return
+	}
+
+	// Worktree must be created serially
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+	if err := r.initializeInitialCommitWorktree(ctx); err != nil {
+		cancel()
+		return
+	}
+
+	go func() {
+		defer cancel()
+
+		if err := r.warmTestCache(ctx, modifiedFile); err != nil {
+			slog.DebugContext(ctx, "cache warming failed", "err", err)
+		}
+	}()
+}
+
+func (r *CodeReviewer) warmTestCache(ctx context.Context, modifiedFile string) error {
+	allPkgs, err := r.packagesForFiles(ctx, []string{r.absPath(modifiedFile)})
+	if err != nil {
+		return fmt.Errorf("failed to get packages for files: %w", err)
+	}
+	if len(allPkgs) == 0 {
+		return nil
+	}
+
+	var pkgPaths []string
+	r.warmMutex.Lock()
+	for pkgPath := range allPkgs {
+		if strings.HasSuffix(pkgPath, ".test") {
+			continue
+		}
+		if r.warmedPackages[pkgPath] {
+			continue
+		}
+		// One attempt is enough.
+		r.warmedPackages[pkgPath] = true
+		pkgPaths = append(pkgPaths, pkgPath)
+	}
+	r.warmMutex.Unlock()
+
+	if len(pkgPaths) == 0 {
+		return nil
+	}
+
+	// Avoid stressing the machine: max 2 concurrent processes.
+	args := []string{"test", "-c", "-p", "2", "-o", "/dev/null"}
+	args = append(args, pkgPaths...)
+
+	cmd := exec.CommandContext(ctx, "go", args...)
+	cmd.Dir = r.initialWorktree
+	cmd.Stdout = io.Discard
+	cmd.Stderr = io.Discard
+
+	slog.DebugContext(ctx, "warming test cache", "packages", len(pkgPaths), "worktree", r.initialWorktree)
+
+	start := time.Now()
+	// Run the command and ignore errors - this is best effort
+	err = cmd.Run()
+	slog.DebugContext(ctx, "cache warming complete", "duration", time.Since(start), "error", err)
+	return nil
+}