From 123d0b2bed662751251327bb4f96befea554c436 Mon Sep 17 00:00:00 2001 From: nux Date: Thu, 22 Jan 2026 12:12:35 -0800 Subject: [PATCH] feat(templates): Add local override support for role templates Adds ability to override embedded role templates at town or rig level: - Town: /templates/roles/.md.tmpl - Rig: /templates/roles/.md.tmpl Rig-level overrides take precedence over town-level. This enables customizing polecat (or other role) behavior per-rig without modifying gastown source, following the same 3-tier override pattern as role configs. New APIs: - NewWithOverrides(townRoot, rigPath string) - loads templates with overrides - HasRoleOverride(role string) bool - check if role has override - RoleOverrideCount() int - count of loaded overrides Implements sc-6ghhn Co-Authored-By: Claude Opus 4.5 --- internal/cmd/prime_output.go | 10 +- internal/templates/templates.go | 94 +++++++++++++--- internal/templates/templates_test.go | 162 +++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 17 deletions(-) diff --git a/internal/cmd/prime_output.go b/internal/cmd/prime_output.go index 04ae9e0f..7bf7a7bc 100644 --- a/internal/cmd/prime_output.go +++ b/internal/cmd/prime_output.go @@ -18,8 +18,14 @@ import ( // outputPrimeContext outputs the role-specific context using templates or fallback. func outputPrimeContext(ctx RoleContext) error { - // Try to use templates first - tmpl, err := templates.New() + // Calculate rig path for template overrides + var rigPath string + if ctx.Rig != "" && ctx.TownRoot != "" { + rigPath = filepath.Join(ctx.TownRoot, ctx.Rig) + } + + // Try to use templates with override support + tmpl, err := templates.NewWithOverrides(ctx.TownRoot, rigPath) if err != nil { // Fall back to hardcoded output if templates fail return outputPrimeContextFallback(ctx) diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 294c6143..e7dda5f7 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -20,22 +20,25 @@ var commandsFS embed.FS type Templates struct { roleTemplates *template.Template messageTemplates *template.Template + // roleOverrides maps role name to override template content. + // When set, these are used instead of embedded templates. + roleOverrides map[string]*template.Template } // RoleData contains information for rendering role contexts. type RoleData struct { - Role string // mayor, witness, refinery, polecat, crew, deacon - RigName string // e.g., "greenplace" - TownRoot string // e.g., "/Users/steve/ai" - TownName string // e.g., "ai" - the town identifier for session names - WorkDir string // current working directory - DefaultBranch string // default branch for merges (e.g., "main", "develop") - Polecat string // polecat name (for polecat role) - Polecats []string // list of polecats (for witness role) - BeadsDir string // BEADS_DIR path - IssuePrefix string // beads issue prefix - MayorSession string // e.g., "gt-ai-mayor" - dynamic mayor session name - DeaconSession string // e.g., "gt-ai-deacon" - dynamic deacon session name + Role string // mayor, witness, refinery, polecat, crew, deacon + RigName string // e.g., "greenplace" + TownRoot string // e.g., "/Users/steve/ai" + TownName string // e.g., "ai" - the town identifier for session names + WorkDir string // current working directory + DefaultBranch string // default branch for merges (e.g., "main", "develop") + Polecat string // polecat name (for polecat role) + Polecats []string // list of polecats (for witness role) + BeadsDir string // BEADS_DIR path + IssuePrefix string // beads issue prefix + MayorSession string // e.g., "gt-ai-mayor" - dynamic mayor session name + DeaconSession string // e.g., "gt-ai-deacon" - dynamic deacon session name } // SpawnData contains information for spawn assignment messages. @@ -81,11 +84,24 @@ type HandoffData struct { GitDirty bool } -// New creates a new Templates instance. +// New creates a new Templates instance with only embedded templates. func New() (*Templates, error) { - t := &Templates{} + return NewWithOverrides("", "") +} - // Parse role templates +// NewWithOverrides creates a Templates instance that checks for local overrides. +// Override resolution order (later overrides earlier): +// 1. Built-in templates (embedded in binary) +// 2. Town-level overrides (/templates/roles/.md.tmpl) +// 3. Rig-level overrides (/templates/roles/.md.tmpl) +// +// If townRoot or rigPath is empty, that level is skipped. +func NewWithOverrides(townRoot, rigPath string) (*Templates, error) { + t := &Templates{ + roleOverrides: make(map[string]*template.Template), + } + + // Parse embedded role templates roleTempl, err := template.ParseFS(templateFS, "roles/*.md.tmpl") if err != nil { return nil, fmt.Errorf("parsing role templates: %w", err) @@ -99,14 +115,51 @@ func New() (*Templates, error) { } t.messageTemplates = msgTempl + // Load role overrides from filesystem + // Check both town and rig levels, rig takes precedence + roles := []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"} + overridePaths := []string{} + if townRoot != "" { + overridePaths = append(overridePaths, filepath.Join(townRoot, "templates", "roles")) + } + if rigPath != "" { + overridePaths = append(overridePaths, filepath.Join(rigPath, "templates", "roles")) + } + + for _, role := range roles { + templateName := role + ".md.tmpl" + // Check each override path in order (later paths override earlier) + for _, overrideDir := range overridePaths { + overridePath := filepath.Join(overrideDir, templateName) + if content, err := os.ReadFile(overridePath); err == nil { + tmpl, err := template.New(templateName).Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("parsing override template %s: %w", overridePath, err) + } + t.roleOverrides[role] = tmpl + } + } + } + return t, nil } // RenderRole renders a role context template. +// If an override template exists for the role, it is used instead of the embedded template. func (t *Templates) RenderRole(role string, data RoleData) (string, error) { templateName := role + ".md.tmpl" var buf bytes.Buffer + + // Check for override template first + if override, ok := t.roleOverrides[role]; ok { + if err := override.Execute(&buf, data); err != nil { + return "", fmt.Errorf("rendering override template %s: %w", templateName, err) + } + return buf.String(), nil + } + + // Fall back to embedded template if err := t.roleTemplates.ExecuteTemplate(&buf, templateName, data); err != nil { return "", fmt.Errorf("rendering role template %s: %w", templateName, err) } @@ -131,6 +184,17 @@ func (t *Templates) RoleNames() []string { return []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"} } +// HasRoleOverride returns true if the given role has a local override template. +func (t *Templates) HasRoleOverride(role string) bool { + _, ok := t.roleOverrides[role] + return ok +} + +// RoleOverrideCount returns the number of role templates that have local overrides. +func (t *Templates) RoleOverrideCount() int { + return len(t.roleOverrides) +} + // MessageNames returns the list of available message templates. func (t *Templates) MessageNames() []string { return []string{"spawn", "nudge", "escalation", "handoff"} diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go index 519cc4eb..2d11d79a 100644 --- a/internal/templates/templates_test.go +++ b/internal/templates/templates_test.go @@ -1,6 +1,7 @@ package templates import ( + "os" "strings" "testing" ) @@ -295,3 +296,164 @@ func TestGetAllRoleTemplates_ContentValidity(t *testing.T) { } } } + +func TestNewWithOverrides_NoOverrides(t *testing.T) { + // When no override paths exist, should work like New() + tmpl, err := NewWithOverrides("/nonexistent/town", "/nonexistent/rig") + if err != nil { + t.Fatalf("NewWithOverrides() error = %v", err) + } + if tmpl == nil { + t.Fatal("NewWithOverrides() returned nil") + } + + // Should have no overrides + if tmpl.RoleOverrideCount() != 0 { + t.Errorf("RoleOverrideCount() = %d, want 0", tmpl.RoleOverrideCount()) + } + + // Should still render embedded templates + data := RoleData{ + Role: "polecat", + RigName: "myrig", + Polecat: "TestCat", + } + output, err := tmpl.RenderRole("polecat", data) + if err != nil { + t.Fatalf("RenderRole() error = %v", err) + } + if !strings.Contains(output, "Polecat Context") { + t.Error("embedded template not rendered correctly") + } +} + +func TestNewWithOverrides_WithOverride(t *testing.T) { + // Create a temp directory with an override template + tempDir := t.TempDir() + overrideDir := tempDir + "/templates/roles" + if err := os.MkdirAll(overrideDir, 0755); err != nil { + t.Fatalf("failed to create override dir: %v", err) + } + + // Write a simple override template + overrideContent := `# Custom Polecat Context +You are polecat {{ .Polecat }} with custom instructions. +Rig: {{ .RigName }} +` + if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(overrideContent), 0644); err != nil { + t.Fatalf("failed to write override template: %v", err) + } + + // Create Templates with the override + tmpl, err := NewWithOverrides("", tempDir) + if err != nil { + t.Fatalf("NewWithOverrides() error = %v", err) + } + + // Should have one override + if tmpl.RoleOverrideCount() != 1 { + t.Errorf("RoleOverrideCount() = %d, want 1", tmpl.RoleOverrideCount()) + } + + // Should report polecat as having override + if !tmpl.HasRoleOverride("polecat") { + t.Error("HasRoleOverride('polecat') = false, want true") + } + if tmpl.HasRoleOverride("mayor") { + t.Error("HasRoleOverride('mayor') = true, want false") + } + + // Render should use override + data := RoleData{ + Role: "polecat", + RigName: "myrig", + Polecat: "TestCat", + } + output, err := tmpl.RenderRole("polecat", data) + if err != nil { + t.Fatalf("RenderRole() error = %v", err) + } + + // Should contain custom content, not embedded + if !strings.Contains(output, "Custom Polecat Context") { + t.Error("override template not used - missing 'Custom Polecat Context'") + } + if strings.Contains(output, "Idle Polecat Heresy") { + t.Error("embedded template used instead of override") + } + if !strings.Contains(output, "TestCat") { + t.Error("template variable not expanded") + } +} + +func TestNewWithOverrides_RigOverridesTown(t *testing.T) { + // Create temp dirs for both town and rig overrides + townDir := t.TempDir() + rigDir := t.TempDir() + + townOverrideDir := townDir + "/templates/roles" + rigOverrideDir := rigDir + "/templates/roles" + + if err := os.MkdirAll(townOverrideDir, 0755); err != nil { + t.Fatalf("failed to create town override dir: %v", err) + } + if err := os.MkdirAll(rigOverrideDir, 0755); err != nil { + t.Fatalf("failed to create rig override dir: %v", err) + } + + // Write town override + townContent := "# Town Polecat\nTown override: {{ .Polecat }}" + if err := os.WriteFile(townOverrideDir+"/polecat.md.tmpl", []byte(townContent), 0644); err != nil { + t.Fatalf("failed to write town override: %v", err) + } + + // Write rig override (should win) + rigContent := "# Rig Polecat\nRig override: {{ .Polecat }}" + if err := os.WriteFile(rigOverrideDir+"/polecat.md.tmpl", []byte(rigContent), 0644); err != nil { + t.Fatalf("failed to write rig override: %v", err) + } + + // Create Templates with both overrides + tmpl, err := NewWithOverrides(townDir, rigDir) + if err != nil { + t.Fatalf("NewWithOverrides() error = %v", err) + } + + // Render should use rig override (higher precedence) + data := RoleData{Polecat: "TestCat"} + output, err := tmpl.RenderRole("polecat", data) + if err != nil { + t.Fatalf("RenderRole() error = %v", err) + } + + if !strings.Contains(output, "Rig Polecat") { + t.Error("rig override not used") + } + if strings.Contains(output, "Town Polecat") { + t.Error("town override used instead of rig override") + } +} + +func TestNewWithOverrides_InvalidTemplate(t *testing.T) { + // Create a temp directory with an invalid template + tempDir := t.TempDir() + overrideDir := tempDir + "/templates/roles" + if err := os.MkdirAll(overrideDir, 0755); err != nil { + t.Fatalf("failed to create override dir: %v", err) + } + + // Write an invalid template (unclosed action) + invalidContent := "# Invalid Template\n{{ .Polecat" + if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(invalidContent), 0644); err != nil { + t.Fatalf("failed to write invalid template: %v", err) + } + + // Should return error for invalid template + _, err := NewWithOverrides("", tempDir) + if err == nil { + t.Error("NewWithOverrides() should fail with invalid template") + } + if !strings.Contains(err.Error(), "parsing override template") { + t.Errorf("error should mention parsing override template: %v", err) + } +}