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>
337 lines
9.9 KiB
Go
337 lines
9.9 KiB
Go
// Package templates provides embedded templates for role contexts and messages.
|
|
package templates
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"text/template"
|
|
)
|
|
|
|
//go:embed roles/*.md.tmpl messages/*.md.tmpl
|
|
var templateFS embed.FS
|
|
|
|
//go:embed commands/*.md
|
|
var commandsFS embed.FS
|
|
|
|
// Templates manages role and message templates.
|
|
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
|
|
}
|
|
|
|
// SpawnData contains information for spawn assignment messages.
|
|
type SpawnData struct {
|
|
Issue string
|
|
Title string
|
|
Priority int
|
|
Description string
|
|
Branch string
|
|
RigName string
|
|
Polecat string
|
|
}
|
|
|
|
// NudgeData contains information for nudge messages.
|
|
type NudgeData struct {
|
|
Polecat string
|
|
Reason string
|
|
NudgeCount int
|
|
MaxNudges int
|
|
Issue string
|
|
Status string
|
|
}
|
|
|
|
// EscalationData contains information for escalation messages.
|
|
type EscalationData struct {
|
|
Polecat string
|
|
Issue string
|
|
Reason string
|
|
NudgeCount int
|
|
LastStatus string
|
|
Suggestions []string
|
|
}
|
|
|
|
// HandoffData contains information for session handoff messages.
|
|
type HandoffData struct {
|
|
Role string
|
|
CurrentWork string
|
|
Status string
|
|
NextSteps []string
|
|
Notes string
|
|
PendingMail int
|
|
GitBranch string
|
|
GitDirty bool
|
|
}
|
|
|
|
// New creates a new Templates instance with only embedded templates.
|
|
func New() (*Templates, error) {
|
|
return NewWithOverrides("", "")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
t.roleTemplates = roleTempl
|
|
|
|
// Parse message templates
|
|
msgTempl, err := template.ParseFS(templateFS, "messages/*.md.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing message templates: %w", err)
|
|
}
|
|
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)
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// RenderMessage renders a message template.
|
|
func (t *Templates) RenderMessage(name string, data interface{}) (string, error) {
|
|
templateName := name + ".md.tmpl"
|
|
|
|
var buf bytes.Buffer
|
|
if err := t.messageTemplates.ExecuteTemplate(&buf, templateName, data); err != nil {
|
|
return "", fmt.Errorf("rendering message template %s: %w", templateName, err)
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// RoleNames returns the list of available role templates.
|
|
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"}
|
|
}
|
|
|
|
// CreateMayorCLAUDEmd creates the Mayor's CLAUDE.md file at the specified directory.
|
|
// This is used by both gt install and gt doctor --fix.
|
|
func CreateMayorCLAUDEmd(mayorDir, townRoot, townName, mayorSession, deaconSession string) error {
|
|
tmpl, err := New()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data := RoleData{
|
|
Role: "mayor",
|
|
TownRoot: townRoot,
|
|
TownName: townName,
|
|
WorkDir: mayorDir,
|
|
MayorSession: mayorSession,
|
|
DeaconSession: deaconSession,
|
|
}
|
|
|
|
content, err := tmpl.RenderRole("mayor", data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
claudePath := filepath.Join(mayorDir, "CLAUDE.md")
|
|
return os.WriteFile(claudePath, []byte(content), 0644)
|
|
}
|
|
|
|
// GetAllRoleTemplates returns all role templates as a map of filename to content.
|
|
func GetAllRoleTemplates() (map[string][]byte, error) {
|
|
entries, err := templateFS.ReadDir("roles")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading roles directory: %w", err)
|
|
}
|
|
|
|
result := make(map[string][]byte)
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
content, err := templateFS.ReadFile("roles/" + entry.Name())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", entry.Name(), err)
|
|
}
|
|
result[entry.Name()] = content
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ProvisionCommands creates the .claude/commands/ directory with standard slash commands.
|
|
// This ensures crew/polecat workspaces have the handoff command and other utilities
|
|
// even if the source repo doesn't have them tracked.
|
|
// If a command already exists, it is skipped (no overwrite).
|
|
func ProvisionCommands(workspacePath string) error {
|
|
entries, err := commandsFS.ReadDir("commands")
|
|
if err != nil {
|
|
return fmt.Errorf("reading commands directory: %w", err)
|
|
}
|
|
|
|
// Create .claude/commands/ directory
|
|
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
|
if err := os.MkdirAll(commandsDir, 0755); err != nil {
|
|
return fmt.Errorf("creating commands directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
destPath := filepath.Join(commandsDir, entry.Name())
|
|
|
|
// Skip if command already exists (don't overwrite user customizations)
|
|
if _, err := os.Stat(destPath); err == nil {
|
|
continue
|
|
}
|
|
|
|
content, err := commandsFS.ReadFile("commands/" + entry.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("reading %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
if err := os.WriteFile(destPath, content, 0644); err != nil { //nolint:gosec // G306: template files are non-sensitive
|
|
return fmt.Errorf("writing %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CommandNames returns the list of embedded slash commands.
|
|
func CommandNames() ([]string, error) {
|
|
entries, err := commandsFS.ReadDir("commands")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading commands directory: %w", err)
|
|
}
|
|
|
|
var names []string
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
names = append(names, entry.Name())
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// HasCommands checks if a workspace has the .claude/commands/ directory provisioned.
|
|
func HasCommands(workspacePath string) bool {
|
|
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
|
info, err := os.Stat(commandsDir)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// MissingCommands returns the list of embedded commands missing from the workspace.
|
|
func MissingCommands(workspacePath string) ([]string, error) {
|
|
entries, err := commandsFS.ReadDir("commands")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading commands directory: %w", err)
|
|
}
|
|
|
|
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
|
var missing []string
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
destPath := filepath.Join(commandsDir, entry.Name())
|
|
if _, err := os.Stat(destPath); os.IsNotExist(err) {
|
|
missing = append(missing, entry.Name())
|
|
}
|
|
}
|
|
|
|
return missing, nil
|
|
}
|