claudetool/codereview: new package extracted from claudetool

Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/claudetool/codereview/llm_review.go b/claudetool/codereview/llm_review.go
new file mode 100644
index 0000000..541ee52
--- /dev/null
+++ b/claudetool/codereview/llm_review.go
@@ -0,0 +1,50 @@
+package codereview
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"os/exec"
+	"strings"
+
+	"sketch.dev/llm"
+	"sketch.dev/llm/conversation"
+)
+
+//go:embed llm_codereview_prompt.txt
+var llmCodereviewPrompt string
+
+// doLLMReview analyzes the diff using an LLM subagent.
+func (r *CodeReviewer) doLLMReview(ctx context.Context) (string, error) {
+	// Get the full diff between initial commit and HEAD
+	cmd := exec.CommandContext(ctx, "git", "diff", r.initialCommit, "HEAD")
+	cmd.Dir = r.repoRoot
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("failed to get diff: %w\n%s", err, out)
+	}
+
+	// If no diff, nothing to check
+	if len(out) == 0 {
+		return "", nil
+	}
+
+	info := conversation.ToolCallInfoFromContext(ctx)
+	convo := info.Convo.SubConvo()
+	convo.SystemPrompt = strings.TrimSpace(llmCodereviewPrompt)
+	initialMessage := llm.UserStringMessage("<diff>\n" + string(out) + "\n</diff>")
+
+	resp, err := convo.SendMessage(initialMessage)
+	if err != nil {
+		return "", fmt.Errorf("failed to send LLM codereview message: %w", err)
+	}
+	if len(resp.Content) != 1 {
+		return "", fmt.Errorf("unexpected number of content blocks in LLM codereview response: %d", len(resp.Content))
+	}
+
+	response := resp.Content[0].Text
+	if strings.TrimSpace(response) == "No comments." {
+		return "", nil
+	}
+	return response, nil
+}