Add branch_name parameter to title tool

Modified the title tool to accept a branch_name parameter, which allows specifying
a custom branch name slug instead of deriving it from the title. The branch name
is still cleaned to ensure it's valid.

Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/loop/agent.go b/loop/agent.go
index 5df9200..0b8d593 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -1,6 +1,7 @@
 package loop
 
 import (
+	"cmp"
 	"context"
 	_ "embed"
 	"encoding/json"
@@ -261,6 +262,7 @@
 	startedAt      time.Time
 	originalBudget ant.Budget
 	title          string
+	branchName     string
 	codereview     *claudetool.CodeReviewer
 	// Outside information
 	outsideHostname   string
@@ -366,11 +368,12 @@
 	return a.gitOrigin
 }
 
-// SetTitle sets the title of the conversation.
-func (a *Agent) SetTitle(title string) {
+// SetTitleBranch sets the title and branch name of the conversation.
+func (a *Agent) SetTitleBranch(title, branchName string) {
 	a.mu.Lock()
 	defer a.mu.Unlock()
 	a.title = title
+	a.branchName = branchName
 	// Notify all listeners that the state has changed
 	for _, ch := range a.listeners {
 		close(ch)
@@ -661,29 +664,50 @@
 }
 
 func (a *Agent) titleTool() *ant.Tool {
-	// titleTool creates the title tool that sets the conversation title.
 	title := &ant.Tool{
 		Name:        "title",
-		Description: `Use this tool early in the conversation, BEFORE MAKING ANY GIT COMMITS, to summarize what the chat is about briefly.`,
+		Description: `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`,
 		InputSchema: json.RawMessage(`{
 	"type": "object",
 	"properties": {
 		"title": {
 			"type": "string",
-			"description": "A brief title summarizing what this chat is about"
+			"description": "A concise, descriptive title summarizing what this conversation is about"
+		},
+		"branch_name": {
+			"type": "string",
+			"description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
 		}
 	},
-	"required": ["title"]
+	"required": ["title", "branch_name"]
 }`),
 		Run: func(ctx context.Context, input json.RawMessage) (string, error) {
 			var params struct {
-				Title string `json:"title"`
+				Title      string `json:"title"`
+				BranchName string `json:"branch_name"`
 			}
 			if err := json.Unmarshal(input, &params); err != nil {
 				return "", err
 			}
-			a.SetTitle(params.Title)
-			return fmt.Sprintf("Title set to: %s", params.Title), nil
+			// It's unfortunate to not allow title changes,
+			// but it avoids having multiple branches.
+			t := a.Title()
+			if t != "" {
+				return "", fmt.Errorf("title already set to: %s", t)
+			}
+
+			if params.BranchName == "" {
+				return "", fmt.Errorf("branch_name parameter cannot be empty")
+			}
+			if params.Title == "" {
+				return "", fmt.Errorf("title parameter cannot be empty")
+			}
+
+			branchName := "sketch/" + cleanBranchName(params.BranchName)
+			a.SetTitleBranch(params.Title, branchName)
+
+			response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
+			return response, nil
 		},
 	}
 	return title
@@ -1076,11 +1100,7 @@
 			commits = append(commits, headCommit)
 		}
 
-		cleanTitle := titleToBranch(a.title)
-		if cleanTitle == "" {
-			cleanTitle = a.config.SessionID
-		}
-		branch := "sketch/" + cleanTitle
+		branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
 
 		// TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
 		// if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
@@ -1106,7 +1126,7 @@
 	return commits, nil
 }
 
-func titleToBranch(s string) string {
+func cleanBranchName(s string) string {
 	return strings.Map(func(r rune) rune {
 		// lowercase
 		if r >= 'A' && r <= 'Z' {