loop: always use branch sketch-wip in innie

Modify innie sketch to always create and work on a dedicated 'sketch-wip'
branch for all git operations, pushing this branch to outie instead of
pushing HEAD directly.

Having a dedicated branch name makes it clearer how to operate
inside the container, for both humans and sketch.
It also prevents the container from pushing whatever transient
commits occur while sketch does (say) a bisection or other git work.
It should also prevent sketch from constantly spinning up new
branches as it starts new tasks in a long conversation.

I'd rather have called the branch 'sketch' instead of 'sketch-wip',
but that conflicts with all the branches called 'sketch/foo'. Alas.

This was mostly written by Josh, but I made it work whether or not
sketch-wip already exists as a branch.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s4ea6db2873a60129k
diff --git a/loop/agent.go b/loop/agent.go
index 1d21a48..88e55da 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -316,11 +316,11 @@
 }
 
 // AgentGitState holds the state necessary for pushing to a remote git repo
-// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
+// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
 // any time we notice we need to.
 type AgentGitState struct {
 	mu            sync.Mutex      // protects following
-	lastHEAD      string          // hash of the last HEAD that was pushed to the host
+	lastSketch    string          // hash of the last sketch branch that was pushed to the host
 	gitRemoteAddr string          // HTTP URL of the host git repo
 	upstream      string          // upstream branch for git work
 	seenCommits   map[string]bool // Track git commits we've already seen (by hash)
@@ -1081,7 +1081,8 @@
 		if out, err := cmd.CombinedOutput(); err != nil {
 			return fmt.Errorf("git fetch: %s: %w", out, err)
 		}
-		cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
+		// The -B resets the branch if it already exists (or creates it if it doesn't)
+		cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
 		cmd.Dir = a.workingDir
 		if checkoutOut, err := cmd.CombinedOutput(); err != nil {
 			// Remove git hooks if they exist and retry
@@ -1102,7 +1103,7 @@
 					return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", a.config.Commit, retryOut, retryErr)
 				}
 			} else {
-				return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
+				return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
 			}
 		}
 	}
@@ -1148,7 +1149,7 @@
 		a.codereview = codereview
 
 	}
-	a.gitState.lastHEAD = a.SketchGitBase()
+	a.gitState.lastSketch = a.SketchGitBase()
 	a.convo = a.initConvo()
 	close(a.ready)
 	return nil
@@ -1859,15 +1860,15 @@
 		return msgs, nil, nil
 	}
 
-	head, err := resolveRef(ctx, repoRoot, "HEAD")
+	sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
 	if err != nil {
 		return msgs, nil, err
 	}
-	if head == ags.lastHEAD {
+	if sketch == ags.lastSketch {
 		return msgs, nil, nil // nothing to do
 	}
 	defer func() {
-		ags.lastHEAD = head
+		ags.lastSketch = sketch
 	}()
 
 	// Get new commits. Because it's possible that the agent does rebases, fixups, and
@@ -1879,7 +1880,7 @@
 	// Format: <hash>\0<subject>\0<body>\0
 	// This uses NULL bytes as separators to avoid issues with newlines in commit messages
 	// Limit to 100 commits to avoid overwhelming the user
-	cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
+	cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, sketch)
 	cmd.Dir = repoRoot
 	output, err := cmd.Output()
 	if err != nil {
@@ -1889,16 +1890,16 @@
 	// Parse git log output and filter out already seen commits
 	parsedCommits := parseGitLog(string(output))
 
-	var headCommit *GitCommit
+	var sketchCommit *GitCommit
 
 	// Filter out commits we've already seen
 	for _, commit := range parsedCommits {
-		if commit.Hash == head {
-			headCommit = &commit
+		if commit.Hash == sketch {
+			sketchCommit = &commit
 		}
 
-		// Skip if we've seen this commit before. If our head has changed, always include that.
-		if ags.seenCommits[commit.Hash] && commit.Hash != head {
+		// Skip if we've seen this commit before. If our sketch branch has changed, always include that.
+		if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
 			continue
 		}
 
@@ -1910,12 +1911,12 @@
 	}
 
 	if ags.gitRemoteAddr != "" {
-		if headCommit == nil {
+		if sketchCommit == nil {
 			// I think this can only happen if we have a bug or if there's a race.
-			headCommit = &GitCommit{}
-			headCommit.Hash = head
-			headCommit.Subject = "unknown"
-			commits = append(commits, headCommit)
+			sketchCommit = &GitCommit{}
+			sketchCommit.Hash = sketch
+			sketchCommit.Subject = "unknown"
+			commits = append(commits, sketchCommit)
 		}
 
 		// TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
@@ -1933,7 +1934,7 @@
 			}
 
 			branch := ags.branchNameLocked(branchPrefix)
-			cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
+			cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
 			cmd.Dir = repoRoot
 			out, err = cmd.CombinedOutput()
 
@@ -1953,7 +1954,7 @@
 			msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
 		} else {
 			finalBranch := ags.branchNameLocked(branchPrefix)
-			headCommit.PushedBranch = finalBranch
+			sketchCommit.PushedBranch = finalBranch
 			if ags.retryNumber != originalRetryNumber {
 				// Notify user that the branch name was changed, and why
 				msgs = append(msgs, AgentMessage{
diff --git a/loop/agent_git_test.go b/loop/agent_git_test.go
index ae291e0..0df795d 100644
--- a/loop/agent_git_test.go
+++ b/loop/agent_git_test.go
@@ -77,6 +77,13 @@
 		t.Fatalf("Failed to create sketch-base tag: %v", err)
 	}
 
+	// Create sketch-wip branch (simulating what happens in agent initialization)
+	cmd = exec.Command("git", "checkout", "-b", "sketch-wip")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to create sketch-wip branch: %v", err)
+	}
+
 	// Make a new commit
 	if err := os.WriteFile(testFile, []byte("updated content\n"), 0o644); err != nil {
 		t.Fatalf("Failed to update file: %v", err)
@@ -265,3 +272,106 @@
 		})
 	}
 }
+
+// TestSketchBranchWorkflow tests that the sketch-wip branch is created and used for pushes
+func TestSketchBranchWorkflow(t *testing.T) {
+	// Create a temporary directory for our test git repo
+	tempDir := t.TempDir()
+
+	// Initialize a git repo in the temp directory
+	cmd := exec.Command("git", "init")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to initialize git repo: %v", err)
+	}
+
+	// Configure git user for commits
+	cmd = exec.Command("git", "config", "user.name", "Test User")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to configure git user name: %v", err)
+	}
+
+	cmd = exec.Command("git", "config", "user.email", "test@example.com")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to configure git user email: %v", err)
+	}
+
+	// Make an initial commit
+	testFile := filepath.Join(tempDir, "test.txt")
+	if err := os.WriteFile(testFile, []byte("initial content\n"), 0o644); err != nil {
+		t.Fatalf("Failed to write file: %v", err)
+	}
+
+	cmd = exec.Command("git", "add", "test.txt")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to add file: %v", err)
+	}
+
+	cmd = exec.Command("git", "commit", "-m", "Initial commit")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to create initial commit: %v", err)
+	}
+
+	// Create agent with configuration that would create sketch-wip branch
+	agent := NewAgent(AgentConfig{
+		Context:   context.Background(),
+		SessionID: "test-session",
+		InDocker:  true,
+	})
+	agent.workingDir = tempDir
+
+	// Simulate the branch creation that happens in Init()
+	// when a commit is specified
+	cmd = exec.Command("git", "checkout", "-b", "sketch-wip")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to create sketch-wip branch: %v", err)
+	}
+
+	// Verify that we're on the sketch-wip branch
+	cmd = exec.Command("git", "branch", "--show-current")
+	cmd.Dir = tempDir
+	out, err := cmd.Output()
+	if err != nil {
+		t.Fatalf("Failed to get current branch: %v", err)
+	}
+
+	currentBranch := strings.TrimSpace(string(out))
+	if currentBranch != "sketch-wip" {
+		t.Errorf("Expected to be on 'sketch-wip' branch, but on '%s'", currentBranch)
+	}
+
+	// Make a commit on the sketch-wip branch
+	if err := os.WriteFile(testFile, []byte("updated content\n"), 0o644); err != nil {
+		t.Fatalf("Failed to update file: %v", err)
+	}
+
+	cmd = exec.Command("git", "add", "test.txt")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to add updated file: %v", err)
+	}
+
+	cmd = exec.Command("git", "commit", "-m", "Update on sketch-wip branch")
+	cmd.Dir = tempDir
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to create commit on sketch-wip branch: %v", err)
+	}
+
+	// Verify that the commit exists on the sketch-wip branch
+	cmd = exec.Command("git", "log", "--oneline", "-n", "1")
+	cmd.Dir = tempDir
+	out, err = cmd.Output()
+	if err != nil {
+		t.Fatalf("Failed to get git log: %v", err)
+	}
+
+	logOutput := string(out)
+	if !strings.Contains(logOutput, "Update on sketch-wip branch") {
+		t.Errorf("Expected commit 'Update on sketch-wip branch' in log, got: %s", logOutput)
+	}
+}
diff --git a/loop/agent_system_prompt.txt b/loop/agent_system_prompt.txt
index 1a3e633..576edc4 100644
--- a/loop/agent_system_prompt.txt
+++ b/loop/agent_system_prompt.txt
@@ -36,6 +36,8 @@
 the done tool, run all the tools the done tool checklist asks
 for, including creating a git commit. Do not forget to run tests.
 
+Commit work to the 'sketch-wip' branch. Changes on other branches will not be pushed to the user.
+
 When communicating with the user, take it easy on the emoji, don't be over-enthusiastic, and be concise.
 </workflow>
 
@@ -105,6 +107,9 @@
 <HEAD>
 {{.InitialCommit}}
 </HEAD>
+<branch>
+sketch-wip
+</branch>
 </git_info>
 
 {{ with .Codebase -}}
diff --git a/loop/testdata/agent_loop.httprr b/loop/testdata/agent_loop.httprr
index d986b0f..fdeb505 100644
--- a/loop/testdata/agent_loop.httprr
+++ b/loop/testdata/agent_loop.httprr
@@ -1,9 +1,9 @@
 httprr trace v1
-20364 2575
+20518 2543
 POST https://api.anthropic.com/v1/messages HTTP/1.1

 Host: api.anthropic.com

 User-Agent: Go-http-client/1.1

-Content-Length: 20166

+Content-Length: 20320

 Anthropic-Version: 2023-06-01

 Content-Type: application/json

 

@@ -586,7 +586,7 @@
  ],
  "system": [
   {
-   "text": "You are the expert software engineer and architect powering Sketch,\nan agentic coding environment that helps users accomplish coding tasks through autonomous analysis and implementation.\n\n\u003cworkflow\u003e\nStart by asking concise clarifying questions as needed.\nOnce the intent is clear, work autonomously.\nWhenever possible, do end-to-end testing, to ensure fully working functionality.\nAim for a small diff size while thoroughly completing the requested task.\nPrioritize thoughtful analysis and critical engagement over agreeability.\n\nCall the set-slug tool as soon as the topic of conversation is clear, often immediately.\n\nBreak down the overall goal into a series of smaller steps.\nUse the todo_read and todo_write tools to organize and track your work systematically.\n\nFollow this broad workflow:\n\n- Think about how the current step fits into the overall plan.\n- Do research. Good tool choices: bash, think, keyword_search\n- Make edits.\n- If you have completed a standalone chunk of work, make a git commit.\n- Update your todo task list.\n- Repeat.\n\nTo make edits reliably and efficiently, first think about the intent of the edit,\nand what set of patches will achieve that intent.\nThen use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call.\n\nYou may run tool calls in parallel.\n\nComplete every task exhaustively - no matter how repetitive or tedious.\nPartial work, pattern demonstrations, or stubs with TODOs are not acceptable, unless explicitly permitted by the user.\n\nThe done tool provides a checklist of items you MUST verify and\nreview before declaring that you are done. Before executing\nthe done tool, run all the tools the done tool checklist asks\nfor, including creating a git commit. Do not forget to run tests.\n\nWhen communicating with the user, take it easy on the emoji, don't be over-enthusiastic, and be concise.\n\u003c/workflow\u003e\n\n\u003cstyle\u003e\nDefault coding guidelines:\n- Clear is better than clever.\n- Minimal inline comments: non-obvious logic and key decisions only.\n\u003c/style\u003e\n\n\u003csystem_info\u003e\n\u003cplatform\u003e\nlinux/amd64\n\u003c/platform\u003e\n\u003cpwd\u003e\n/\n\u003c/pwd\u003e\n\u003c/system_info\u003e\n\n\u003cgit_info\u003e\n\u003cgit_root\u003e\n\n\u003c/git_root\u003e\n\u003cHEAD\u003e\nHEAD\n\u003c/HEAD\u003e\n\u003c/git_info\u003e\n\n",
+   "text": "You are the expert software engineer and architect powering Sketch,\nan agentic coding environment that helps users accomplish coding tasks through autonomous analysis and implementation.\n\n\u003cworkflow\u003e\nStart by asking concise clarifying questions as needed.\nOnce the intent is clear, work autonomously.\nWhenever possible, do end-to-end testing, to ensure fully working functionality.\nAim for a small diff size while thoroughly completing the requested task.\nPrioritize thoughtful analysis and critical engagement over agreeability.\n\nCall the set-slug tool as soon as the topic of conversation is clear, often immediately.\n\nBreak down the overall goal into a series of smaller steps.\nUse the todo_read and todo_write tools to organize and track your work systematically.\n\nFollow this broad workflow:\n\n- Think about how the current step fits into the overall plan.\n- Do research. Good tool choices: bash, think, keyword_search\n- Make edits.\n- If you have completed a standalone chunk of work, make a git commit.\n- Update your todo task list.\n- Repeat.\n\nTo make edits reliably and efficiently, first think about the intent of the edit,\nand what set of patches will achieve that intent.\nThen use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call.\n\nYou may run tool calls in parallel.\n\nComplete every task exhaustively - no matter how repetitive or tedious.\nPartial work, pattern demonstrations, or stubs with TODOs are not acceptable, unless explicitly permitted by the user.\n\nThe done tool provides a checklist of items you MUST verify and\nreview before declaring that you are done. Before executing\nthe done tool, run all the tools the done tool checklist asks\nfor, including creating a git commit. Do not forget to run tests.\n\nCommit work to the 'sketch-wip' branch. Changes on other branches will not be pushed to the user.\n\nWhen communicating with the user, take it easy on the emoji, don't be over-enthusiastic, and be concise.\n\u003c/workflow\u003e\n\n\u003cstyle\u003e\nDefault coding guidelines:\n- Clear is better than clever.\n- Minimal inline comments: non-obvious logic and key decisions only.\n\u003c/style\u003e\n\n\u003csystem_info\u003e\n\u003cplatform\u003e\nlinux/amd64\n\u003c/platform\u003e\n\u003cpwd\u003e\n/\n\u003c/pwd\u003e\n\u003c/system_info\u003e\n\n\u003cgit_info\u003e\n\u003cgit_root\u003e\n\n\u003c/git_root\u003e\n\u003cHEAD\u003e\nHEAD\n\u003c/HEAD\u003e\n\u003cbranch\u003e\nsketch-wip\n\u003c/branch\u003e\n\u003c/git_info\u003e\n\n",
    "type": "text",
    "cache_control": {
     "type": "ephemeral"
@@ -597,24 +597,24 @@
 Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b

 Anthropic-Ratelimit-Input-Tokens-Limit: 200000

 Anthropic-Ratelimit-Input-Tokens-Remaining: 200000

-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-06-05T21:18:00Z

+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-06-06T02:32:46Z

 Anthropic-Ratelimit-Output-Tokens-Limit: 80000

 Anthropic-Ratelimit-Output-Tokens-Remaining: 80000

-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-06-05T21:18:07Z

+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-06-06T02:32:53Z

 Anthropic-Ratelimit-Requests-Limit: 4000

 Anthropic-Ratelimit-Requests-Remaining: 3999

-Anthropic-Ratelimit-Requests-Reset: 2025-06-05T21:17:58Z

+Anthropic-Ratelimit-Requests-Reset: 2025-06-06T02:32:45Z

 Anthropic-Ratelimit-Tokens-Limit: 280000

 Anthropic-Ratelimit-Tokens-Remaining: 280000

-Anthropic-Ratelimit-Tokens-Reset: 2025-06-05T21:18:00Z

+Anthropic-Ratelimit-Tokens-Reset: 2025-06-06T02:32:46Z

 Cf-Cache-Status: DYNAMIC

-Cf-Ray: 94b2b328b80e5c22-SJC

+Cf-Ray: 94b48040fcd0cf13-SJC

 Content-Type: application/json

-Date: Thu, 05 Jun 2025 21:18:07 GMT

-Request-Id: req_011CPqmeAPjukVrzXpwQzJNv

+Date: Fri, 06 Jun 2025 02:32:53 GMT

+Request-Id: req_011CPrBeN6F4FRvcy9wGBJKv

 Server: cloudflare

 Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

 Via: 1.1 google

 X-Robots-Tag: none

 

-{"id":"msg_0147wxZbm7bkJ7gxhgALTjJb","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Operations:**\n- `bash` - Execute shell commands\n- `keyword_search` - Search for files/code with keywords\n- `patch` - Make precise text edits to files\n\n**Task Management:**\n- `think` - Take notes and form plans\n- `todo_read` / `todo_write` - Track and manage task lists\n\n**Git & Code Quality:**\n- `commit-message-style` - Get git commit message guidance\n- `codereview` - Run automated code review\n- `done` - Complete work with verification checklist\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into form fields\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page content\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll to elements\n- `browser_resize` - Resize browser window\n- `browser_recent_console_logs` - Get console logs\n- `browser_clear_console_logs` - Clear console logs\n- `browser_take_screenshot` - Capture screenshots\n- `browser_read_image` - Read image files\n\n**Interaction:**\n- `set-slug` - Set conversation identifier\n- `about_sketch` - Get help with Sketch functionality\n- `multiplechoice` - Present multiple choice questions"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4672,"cache_read_input_tokens":0,"output_tokens":338,"service_tier":"standard"}}
\ No newline at end of file
+{"id":"msg_01Qv8V3fRxSHp6hnN9jhyr4Q","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Management:**\n- `bash` - Execute shell commands\n- `keyword_search` - Search for files/code with keywords\n- `patch` - Modify files with precise text edits\n\n**Planning & Organization:**\n- `think` - Take notes and form plans\n- `todo_read` / `todo_write` - Read and manage task lists\n\n**Git & Code Quality:**\n- `commit-message-style` - Get git commit message guidance\n- `codereview` - Run automated code review\n- `done` - Complete work with verification checklist\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into input fields\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page text\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll to elements\n- `browser_resize` - Resize browser window\n- `browser_recent_console_logs` - Get console logs\n- `browser_clear_console_logs` - Clear console logs\n- `browser_take_screenshot` - Take screenshots\n- `browser_read_image` - Read image files\n\n**Utilities:**\n- `about_sketch` - Get help with Sketch functionality\n- `multiplechoice` - Present multiple choice questions to user"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4712,"cache_read_input_tokens":0,"output_tokens":330,"service_tier":"standard"}}
\ No newline at end of file