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{} {