loop: migrate system prompt to Go templates

It was bound to happen eventually.
Bite the bullet now.

Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/loop/agent.go b/loop/agent.go
index b12c030..6ce408a 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -15,6 +15,7 @@
 	"slices"
 	"strings"
 	"sync"
+	"text/template"
 	"time"
 
 	"sketch.dev/browser"
@@ -807,15 +808,7 @@
 	convo := conversation.New(ctx, a.config.Service)
 	convo.PromptCaching = true
 	convo.Budget = a.config.Budget
-
-	var editPrompt string
-	if a.config.UseAnthropicEdit {
-		editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
-	} else {
-		editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
-	}
-
-	convo.SystemPrompt = fmt.Sprintf(agentSystemPrompt, editPrompt, a.config.ClientGOOS, a.config.ClientGOARCH, a.workingDir, a.repoRoot, a.initialCommit)
+	convo.SystemPrompt = a.renderSystemPrompt()
 
 	// Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
 	bashPermissionCheck := func(command string) error {
@@ -1704,3 +1697,44 @@
 	textContent := collectTextContent(resp)
 	return textContent, nil
 }
+
+// systemPromptData contains the data used to render the system prompt template
+type systemPromptData struct {
+	EditPrompt    string
+	ClientGOOS    string
+	ClientGOARCH  string
+	WorkingDir    string
+	RepoRoot      string
+	InitialCommit string
+}
+
+// renderSystemPrompt renders the system prompt template.
+func (a *Agent) renderSystemPrompt() string {
+	// Determine the appropriate edit prompt based on config
+	var editPrompt string
+	if a.config.UseAnthropicEdit {
+		editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
+	} else {
+		editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
+	}
+
+	data := systemPromptData{
+		EditPrompt:    editPrompt,
+		ClientGOOS:    a.config.ClientGOOS,
+		ClientGOARCH:  a.config.ClientGOARCH,
+		WorkingDir:    a.workingDir,
+		RepoRoot:      a.repoRoot,
+		InitialCommit: a.initialCommit,
+	}
+
+	tmpl, err := template.New("system").Parse(agentSystemPrompt)
+	if err != nil {
+		panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
+	}
+	buf := new(strings.Builder)
+	err = tmpl.Execute(buf, data)
+	if err != nil {
+		panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
+	}
+	return buf.String()
+}
diff --git a/loop/agent_system_prompt.txt b/loop/agent_system_prompt.txt
index 8049248..b6b7de3 100644
--- a/loop/agent_system_prompt.txt
+++ b/loop/agent_system_prompt.txt
@@ -21,7 +21,7 @@
 
 To make edits reliably and efficiently, first think about the intent of the edit,
 and what set of patches will achieve that intent.
-%s
+{{.EditPrompt}}
 
 For renames or refactors, consider invoking gopls (via bash).
 
@@ -31,14 +31,14 @@
 for, including creating a git commit. Do not forget to run tests.
 
 <platform>
-%s/%s
+{{.ClientGOOS}}/{{.ClientGOARCH}}
 </platform>
 <pwd>
-%v
+{{.WorkingDir}}
 </pwd>
 <git_root>
-%v
+{{.RepoRoot}}
 </git_root>
 <HEAD>
-%v
+{{.InitialCommit}}
 </HEAD>