claudetool: change title tool to precommit, add commit style guidance
diff --git a/claudetool/pre-commit.go b/claudetool/pre-commit.go
new file mode 100644
index 0000000..7282948
--- /dev/null
+++ b/claudetool/pre-commit.go
@@ -0,0 +1,93 @@
+package claudetool
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"strings"
+
+	"sketch.dev/llm/conversation"
+)
+
+// CommitMessageStyleHint explains how to find commit messages representative of this repository's style.
+func CommitMessageStyleHint(ctx context.Context, repoRoot string) (string, error) {
+	commitSHAs, err := representativeCommitSHAs(ctx, repoRoot)
+	if err != nil {
+		return "", err
+	}
+
+	buf := new(strings.Builder)
+	if len(commitSHAs) > 0 {
+		fmt.Fprint(buf, "To see representative commit messages for this repository, run:\n\n")
+		fmt.Fprintf(buf, "git show -s --format='<commit_message>%%n%%B</commit_message>' %s\n\n", strings.Join(commitSHAs, " "))
+		fmt.Fprint(buf, "Please run this EXACT command and follow their style when writing commit messages.\n")
+	}
+
+	return buf.String(), nil
+}
+
+// representativeCommitSHAs analyze recent commits and selects some representative ones.
+func representativeCommitSHAs(ctx context.Context, repoRoot string) ([]string, error) {
+	cmd := exec.Command("git", "log", "-n", "25", `--format=<commit_message hash="%H">%n%B</commit_message>`)
+	cmd.Dir = repoRoot
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("git log failed: %w\n%s", err, out)
+	}
+	commits := strings.TrimSpace(string(out))
+	if commits == "" {
+		return nil, fmt.Errorf("no commits found in repository")
+	}
+
+	info := conversation.ToolCallInfoFromContext(ctx)
+	sub := info.Convo.SubConvo()
+
+	sub.SystemPrompt = `You are an expert Git commit analyzer.
+
+Your task is to analyze the provided commit messages and select the most representative examples that demonstrate this repository's commit style.
+
+Identify consistent patterns in:
+- Formatting conventions
+- Language and tone
+- Structure and organization
+- Special notations or tags
+
+Select up to 3 commit hashes that best exemplify the repository's commit style.
+
+Provide ONLY the commit hashes, one per line. No additional text, formatting, or commentary.
+`
+
+	resp, err := sub.SendUserTextMessage(commits)
+	if err != nil {
+		return nil, fmt.Errorf("error from Claude: %w", err)
+	}
+
+	if len(resp.Content) != 1 {
+		return nil, fmt.Errorf("unexpected response: %v", resp)
+	}
+	response := resp.Content[0].Text
+
+	var result []string
+	for line := range strings.Lines(response) {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+		if isHexString(line) && (len(line) >= 7 && len(line) <= 40) {
+			result = append(result, line)
+		}
+	}
+
+	result = result[:min(len(result), 3)]
+	return result, nil
+}
+
+// isHexString reports whether a string only contains hexadecimal characters
+func isHexString(s string) bool {
+	for _, c := range s {
+		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
+			return false
+		}
+	}
+	return true
+}
diff --git a/experiment/experiment.go b/experiment/experiment.go
index 6f126ab..826c56c 100644
--- a/experiment/experiment.go
+++ b/experiment/experiment.go
@@ -35,6 +35,10 @@
 			Name:        "llm_review",
 			Description: "Add an LLM step to the codereview tool",
 		},
+		{
+			Name:        "precommit",
+			Description: "Changes title tool to a precommit tool that provides commit message style guidance",
+		},
 	}
 	byName = map[string]*Experiment{}
 )
diff --git a/loop/agent.go b/loop/agent.go
index 0ec6c3f..0c1f2ff 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -833,7 +833,7 @@
 	// template in termui/termui.go has pretty-printing support for all tools.
 	convo.Tools = []*llm.Tool{
 		bashTool, claudetool.Keyword,
-		claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
+		claudetool.Think, a.preCommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
 		a.codereview.Tool(), a.multipleChoiceTool(),
 	}
 	if a.config.UseAnthropicEdit {
@@ -917,10 +917,16 @@
 	return false
 }
 
-func (a *Agent) titleTool() *llm.Tool {
-	title := &llm.Tool{
-		Name:        "title",
-		Description: `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`,
+func (a *Agent) preCommitTool() *llm.Tool {
+	name := "title"
+	description := `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`
+	if experiment.Enabled("precommit") {
+		name = "precommit"
+		description = `Sets the conversation title, creates a git branch for tracking work, and provides git commit message style guidance. MANDATORY: You must use this tool before making any git commits.`
+	}
+	preCommit := &llm.Tool{
+		Name:        name,
+		Description: description,
 		InputSchema: json.RawMessage(`{
 	"type": "object",
 	"properties": {
@@ -944,7 +950,7 @@
 				return "", err
 			}
 			// It's unfortunate to not allow title changes,
-			// but it avoids having multiple branches.
+			// but it avoids accidentally generating multiple branches.
 			t := a.Title()
 			if t != "" {
 				return "", fmt.Errorf("title already set to: %s", t)
@@ -967,10 +973,21 @@
 			a.SetTitleBranch(params.Title, branchName)
 
 			response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
+
+			if experiment.Enabled("precommit") {
+				styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
+				if err != nil {
+					slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
+				}
+				if len(styleHint) > 0 {
+					response += "\n\n" + styleHint
+				}
+			}
+
 			return response, nil
 		},
 	}
-	return title
+	return preCommit
 }
 
 func (a *Agent) Ready() <-chan struct{} {