From 3e5562222d8f5bcf62f5aa248e30aa17d185caba Mon Sep 17 00:00:00 2001 From: Avyukth Date: Sat, 3 Jan 2026 00:34:51 +0530 Subject: [PATCH] fix: Check patrol role templates per-rig instead of at town level - PatrolRolesHavePromptsCheck now verifies templates exist in each rig's mayor clone at /mayor/rig/internal/templates/roles/ - Track missing templates by rig using missingByRig map - Fix copies embedded templates to each rig's location - Add GetAllRoleTemplates helper to templates package - Add tests for no-rigs case and multiple-rigs scenarios --- internal/doctor/patrol_check.go | 58 +++++- internal/doctor/patrol_check_test.go | 255 +++++++++++++++++++++++++++ internal/templates/templates.go | 38 +++- 3 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 internal/doctor/patrol_check_test.go diff --git a/internal/doctor/patrol_check.go b/internal/doctor/patrol_check.go index a64f5aea..be7f2e91 100644 --- a/internal/doctor/patrol_check.go +++ b/internal/doctor/patrol_check.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/templates" ) // PatrolMoleculesExistCheck verifies that patrol molecules exist for each rig. @@ -392,30 +393,36 @@ func (c *PatrolPluginsAccessibleCheck) Fix(ctx *CheckContext) error { return nil } -// PatrolRolesHavePromptsCheck verifies that internal/templates/roles/*.md.tmpl exist for each role. +// PatrolRolesHavePromptsCheck verifies that internal/templates/roles/*.md.tmpl exist for each rig. +// Checks at //mayor/rig/internal/templates/roles/*.md.tmpl +// Fix copies embedded templates to missing locations. type PatrolRolesHavePromptsCheck struct { - BaseCheck + FixableCheck + // missingByRig tracks missing templates per rig: rigName -> []missingFiles + missingByRig map[string][]string } // NewPatrolRolesHavePromptsCheck creates a new patrol roles have prompts check. func NewPatrolRolesHavePromptsCheck() *PatrolRolesHavePromptsCheck { return &PatrolRolesHavePromptsCheck{ - BaseCheck: BaseCheck{ - CheckName: "patrol-roles-have-prompts", - CheckDescription: "Check if internal/templates/roles/*.md.tmpl exist for each patrol role", + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "patrol-roles-have-prompts", + CheckDescription: "Check if internal/templates/roles/*.md.tmpl exist for each patrol role", + }, }, } } -// requiredRolePrompts are the required role prompt template files. var requiredRolePrompts = []string{ "deacon.md.tmpl", "witness.md.tmpl", "refinery.md.tmpl", } -// Run checks if role prompts exist. func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult { + c.missingByRig = make(map[string][]string) + rigs, err := discoverRigs(ctx.TownRoot) if err != nil { return &CheckResult{ @@ -440,12 +447,17 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult { mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig") templatesDir := filepath.Join(mayorRig, "internal", "templates", "roles") + var rigMissing []string for _, roleFile := range requiredRolePrompts { promptPath := filepath.Join(templatesDir, roleFile) if _, err := os.Stat(promptPath); os.IsNotExist(err) { missingPrompts = append(missingPrompts, fmt.Sprintf("%s: %s", rigName, roleFile)) + rigMissing = append(rigMissing, roleFile) } } + if len(rigMissing) > 0 { + c.missingByRig[rigName] = rigMissing + } } if len(missingPrompts) > 0 { @@ -454,7 +466,7 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult { Status: StatusWarning, Message: fmt.Sprintf("%d role prompt template(s) missing", len(missingPrompts)), Details: missingPrompts, - FixHint: "Role prompt templates should be in the project repository under internal/templates/roles/", + FixHint: "Run 'gt doctor --fix' to copy embedded templates to rig repos", } } @@ -465,6 +477,36 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult { } } +func (c *PatrolRolesHavePromptsCheck) Fix(ctx *CheckContext) error { + allTemplates, err := templates.GetAllRoleTemplates() + if err != nil { + return fmt.Errorf("getting embedded templates: %w", err) + } + + for rigName, missingFiles := range c.missingByRig { + mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig") + templatesDir := filepath.Join(mayorRig, "internal", "templates", "roles") + + if err := os.MkdirAll(templatesDir, 0755); err != nil { + return fmt.Errorf("creating %s: %w", templatesDir, err) + } + + for _, roleFile := range missingFiles { + content, ok := allTemplates[roleFile] + if !ok { + continue + } + + destPath := filepath.Join(templatesDir, roleFile) + if err := os.WriteFile(destPath, content, 0644); err != nil { + return fmt.Errorf("writing %s in %s: %w", roleFile, rigName, err) + } + } + } + + return nil +} + // discoverRigs finds all registered rigs. func discoverRigs(townRoot string) ([]string, error) { rigsPath := filepath.Join(townRoot, "mayor", "rigs.json") diff --git a/internal/doctor/patrol_check_test.go b/internal/doctor/patrol_check_test.go new file mode 100644 index 00000000..c45d93fb --- /dev/null +++ b/internal/doctor/patrol_check_test.go @@ -0,0 +1,255 @@ +package doctor + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/config" +) + +func TestNewPatrolRolesHavePromptsCheck(t *testing.T) { + check := NewPatrolRolesHavePromptsCheck() + if check == nil { + t.Fatal("NewPatrolRolesHavePromptsCheck() returned nil") + } + if check.Name() != "patrol-roles-have-prompts" { + t.Errorf("Name() = %q, want %q", check.Name(), "patrol-roles-have-prompts") + } + if !check.CanFix() { + t.Error("CanFix() should return true") + } +} + +func setupRigConfig(t *testing.T, tmpDir string, rigNames []string) { + t.Helper() + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatalf("mkdir mayor: %v", err) + } + + rigsConfig := config.RigsConfig{Rigs: make(map[string]config.RigEntry)} + for _, name := range rigNames { + rigsConfig.Rigs[name] = config.RigEntry{} + } + + data, err := json.Marshal(rigsConfig) + if err != nil { + t.Fatalf("marshal rigs.json: %v", err) + } + + if err := os.WriteFile(filepath.Join(mayorDir, "rigs.json"), data, 0644); err != nil { + t.Fatalf("write rigs.json: %v", err) + } +} + +func setupRigTemplatesDir(t *testing.T, tmpDir, rigName string) string { + t.Helper() + templatesDir := filepath.Join(tmpDir, rigName, "mayor", "rig", "internal", "templates", "roles") + if err := os.MkdirAll(templatesDir, 0755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + return templatesDir +} + +func TestPatrolRolesHavePromptsCheck_NoRigs(t *testing.T) { + tmpDir := t.TempDir() + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("Status = %v, want OK (no rigs configured)", result.Status) + } +} + +func TestPatrolRolesHavePromptsCheck_NoTemplatesDir(t *testing.T) { + tmpDir := t.TempDir() + setupRigConfig(t, tmpDir, []string{"myproject"}) + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("Status = %v, want Warning", result.Status) + } + if len(check.missingByRig) != 1 { + t.Errorf("missingByRig count = %d, want 1", len(check.missingByRig)) + } + if len(check.missingByRig["myproject"]) != 3 { + t.Errorf("missing templates for myproject = %d, want 3", len(check.missingByRig["myproject"])) + } +} + +func TestPatrolRolesHavePromptsCheck_SomeTemplatesMissing(t *testing.T) { + tmpDir := t.TempDir() + setupRigConfig(t, tmpDir, []string{"myproject"}) + templatesDir := setupRigTemplatesDir(t, tmpDir, "myproject") + + if err := os.WriteFile(filepath.Join(templatesDir, "deacon.md.tmpl"), []byte("test"), 0644); err != nil { + t.Fatalf("write deacon.md.tmpl: %v", err) + } + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("Status = %v, want Warning", result.Status) + } + if len(check.missingByRig["myproject"]) != 2 { + t.Errorf("missing templates = %d, want 2 (witness, refinery)", len(check.missingByRig["myproject"])) + } +} + +func TestPatrolRolesHavePromptsCheck_AllTemplatesExist(t *testing.T) { + tmpDir := t.TempDir() + setupRigConfig(t, tmpDir, []string{"myproject"}) + templatesDir := setupRigTemplatesDir(t, tmpDir, "myproject") + + for _, tmpl := range requiredRolePrompts { + if err := os.WriteFile(filepath.Join(templatesDir, tmpl), []byte("test content"), 0644); err != nil { + t.Fatalf("write %s: %v", tmpl, err) + } + } + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("Status = %v, want OK", result.Status) + } + if len(check.missingByRig) != 0 { + t.Errorf("missingByRig count = %d, want 0", len(check.missingByRig)) + } +} + +func TestPatrolRolesHavePromptsCheck_Fix(t *testing.T) { + tmpDir := t.TempDir() + setupRigConfig(t, tmpDir, []string{"myproject"}) + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + if result.Status != StatusWarning { + t.Fatalf("Initial Status = %v, want Warning", result.Status) + } + + err := check.Fix(ctx) + if err != nil { + t.Fatalf("Fix() error = %v", err) + } + + templatesDir := filepath.Join(tmpDir, "myproject", "mayor", "rig", "internal", "templates", "roles") + for _, tmpl := range requiredRolePrompts { + path := filepath.Join(templatesDir, tmpl) + info, err := os.Stat(path) + if err != nil { + t.Errorf("Fix() did not create %s: %v", tmpl, err) + continue + } + if info.Size() == 0 { + t.Errorf("Fix() created empty file %s", tmpl) + } + } + + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("After Fix(), Status = %v, want OK", result.Status) + } +} + +func TestPatrolRolesHavePromptsCheck_FixPartial(t *testing.T) { + tmpDir := t.TempDir() + setupRigConfig(t, tmpDir, []string{"myproject"}) + templatesDir := setupRigTemplatesDir(t, tmpDir, "myproject") + + existingContent := []byte("existing custom content") + if err := os.WriteFile(filepath.Join(templatesDir, "deacon.md.tmpl"), existingContent, 0644); err != nil { + t.Fatalf("write deacon.md.tmpl: %v", err) + } + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + if result.Status != StatusWarning { + t.Fatalf("Initial Status = %v, want Warning", result.Status) + } + if len(check.missingByRig["myproject"]) != 2 { + t.Fatalf("missing = %d, want 2", len(check.missingByRig["myproject"])) + } + + err := check.Fix(ctx) + if err != nil { + t.Fatalf("Fix() error = %v", err) + } + + deaconContent, err := os.ReadFile(filepath.Join(templatesDir, "deacon.md.tmpl")) + if err != nil { + t.Fatalf("read deacon.md.tmpl: %v", err) + } + if string(deaconContent) != string(existingContent) { + t.Error("Fix() should not overwrite existing deacon.md.tmpl") + } + + for _, tmpl := range []string{"witness.md.tmpl", "refinery.md.tmpl"} { + path := filepath.Join(templatesDir, tmpl) + if _, err := os.Stat(path); err != nil { + t.Errorf("Fix() did not create %s: %v", tmpl, err) + } + } +} + +func TestPatrolRolesHavePromptsCheck_MultipleRigs(t *testing.T) { + tmpDir := t.TempDir() + setupRigConfig(t, tmpDir, []string{"project1", "project2"}) + + templatesDir1 := setupRigTemplatesDir(t, tmpDir, "project1") + for _, tmpl := range requiredRolePrompts { + if err := os.WriteFile(filepath.Join(templatesDir1, tmpl), []byte("test"), 0644); err != nil { + t.Fatalf("write %s: %v", tmpl, err) + } + } + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("Status = %v, want Warning (project2 missing)", result.Status) + } + if _, ok := check.missingByRig["project1"]; ok { + t.Error("project1 should not be in missingByRig") + } + if len(check.missingByRig["project2"]) != 3 { + t.Errorf("project2 missing = %d, want 3", len(check.missingByRig["project2"])) + } +} + +func TestPatrolRolesHavePromptsCheck_FixHint(t *testing.T) { + tmpDir := t.TempDir() + setupRigConfig(t, tmpDir, []string{"myproject"}) + + check := NewPatrolRolesHavePromptsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.FixHint == "" { + t.Error("FixHint should not be empty for warning status") + } + if result.FixHint != "Run 'gt doctor --fix' to copy embedded templates to rig repos" { + t.Errorf("FixHint = %q, unexpected value", result.FixHint) + } +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go index a98467b2..97916f1a 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -62,14 +62,14 @@ type EscalationData struct { // 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 + Role string + CurrentWork string + Status string + NextSteps []string + Notes string + PendingMail int + GitBranch string + GitDirty bool } // New creates a new Templates instance. @@ -126,3 +126,25 @@ func (t *Templates) RoleNames() []string { func (t *Templates) MessageNames() []string { return []string{"spawn", "nudge", "escalation", "handoff"} } + +// 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 +}