feat(templates): Add local override support for role templates
Adds ability to override embedded role templates at town or rig level: - Town: <townRoot>/templates/roles/<role>.md.tmpl - Rig: <rigPath>/templates/roles/<role>.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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -20,6 +20,9 @@ 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.
|
||||
@@ -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 (<townRoot>/templates/roles/<role>.md.tmpl)
|
||||
// 3. Rig-level overrides (<rigPath>/templates/roles/<role>.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"}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user