diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 30cc7542..b95f9aee 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -23,6 +23,13 @@ var doctorCmd = &cobra.Command{ Doctor checks for common configuration issues, missing files, and other problems that could affect workspace operation. +Patrol checks: + - patrol-molecules-exist Verify patrol molecules exist + - patrol-hooks-wired Verify daemon triggers patrols + - patrol-not-stuck Detect stale wisps (>1h) + - patrol-plugins-accessible Verify plugin directories + - patrol-roles-have-prompts Verify role prompts exist + Use --fix to attempt automatic fixes for issues that support it. Use --rig to check a specific rig instead of the entire workspace.`, RunE: runDoctor, @@ -71,6 +78,13 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewWispSizeCheck()) d.Register(doctor.NewWispStaleCheck()) + // Patrol system checks + d.Register(doctor.NewPatrolMoleculesExistCheck()) + d.Register(doctor.NewPatrolHooksWiredCheck()) + d.Register(doctor.NewPatrolNotStuckCheck()) + d.Register(doctor.NewPatrolPluginsAccessibleCheck()) + d.Register(doctor.NewPatrolRolesHavePromptsCheck()) + // Config architecture checks d.Register(doctor.NewSettingsCheck()) d.Register(doctor.NewRuntimeGitignoreCheck()) diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index d9860926..06a875dd 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -45,12 +45,18 @@ This creates a rig container with: - config.json Rig configuration - .beads/ Rig-level issue tracking (initialized) - .beads-wisp/ Local wisp/molecule tracking (gitignored) + - plugins/ Rig-level plugin directory - refinery/rig/ Canonical main clone - mayor/rig/ Mayor's working clone - crew/main/ Default human workspace - witness/ Witness agent directory - polecats/ Worker directory (empty) +The command also: + - Seeds patrol molecules (Deacon, Witness, Refinery) + - Creates ~/gt/plugins/ (town-level) if it doesn't exist + - Creates /plugins/ (rig-level) + Example: gt rig add gastown https://github.com/steveyegge/gastown gt rig add my-project git@github.com:user/repo.git --prefix mp`, @@ -201,6 +207,7 @@ func runRigAdd(cmd *cobra.Command, args []string) error { fmt.Printf(" ├── config.json\n") fmt.Printf(" ├── .beads/ (prefix: %s)\n", newRig.Config.Prefix) fmt.Printf(" ├── .beads-wisp/ (local wisp/molecule tracking)\n") + fmt.Printf(" ├── plugins/ (rig-level plugins)\n") fmt.Printf(" ├── refinery/rig/ (canonical main)\n") fmt.Printf(" ├── mayor/rig/ (mayor's clone)\n") fmt.Printf(" ├── crew/%s/ (your workspace)\n", rigAddCrew) diff --git a/internal/doctor/patrol_check.go b/internal/doctor/patrol_check.go new file mode 100644 index 00000000..dcdf19f6 --- /dev/null +++ b/internal/doctor/patrol_check.go @@ -0,0 +1,459 @@ +package doctor + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// PatrolMoleculesExistCheck verifies that patrol molecules exist for each rig. +type PatrolMoleculesExistCheck struct { + FixableCheck + missingMols map[string][]string // rig -> missing molecule titles +} + +// NewPatrolMoleculesExistCheck creates a new patrol molecules exist check. +func NewPatrolMoleculesExistCheck() *PatrolMoleculesExistCheck { + return &PatrolMoleculesExistCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "patrol-molecules-exist", + CheckDescription: "Check if patrol molecules exist for each rig", + }, + }, + } +} + +// patrolMolecules are the required patrol molecule titles. +var patrolMolecules = []string{ + "Deacon Patrol", + "Witness Patrol", + "Refinery Patrol", +} + +// Run checks if patrol molecules exist. +func (c *PatrolMoleculesExistCheck) Run(ctx *CheckContext) *CheckResult { + c.missingMols = make(map[string][]string) + + rigs, err := discoverRigs(ctx.TownRoot) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to discover rigs", + Details: []string{err.Error()}, + } + } + + if len(rigs) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rigs configured", + } + } + + var details []string + for _, rigName := range rigs { + rigPath := filepath.Join(ctx.TownRoot, rigName) + missing := c.checkPatrolMolecules(rigPath) + if len(missing) > 0 { + c.missingMols[rigName] = missing + details = append(details, fmt.Sprintf("%s: missing %v", rigName, missing)) + } + } + + if len(details) > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d rig(s) missing patrol molecules", len(c.missingMols)), + Details: details, + FixHint: "Run 'gt doctor --fix' to create missing patrol molecules", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("All %d rig(s) have patrol molecules", len(rigs)), + } +} + +// checkPatrolMolecules returns missing patrol molecule titles for a rig. +func (c *PatrolMoleculesExistCheck) checkPatrolMolecules(rigPath string) []string { + // List molecules using bd + cmd := exec.Command("bd", "list", "--type=molecule") + cmd.Dir = rigPath + output, err := cmd.Output() + if err != nil { + return patrolMolecules // Can't check, assume all missing + } + + outputStr := string(output) + var missing []string + for _, mol := range patrolMolecules { + if !strings.Contains(outputStr, mol) { + missing = append(missing, mol) + } + } + return missing +} + +// Fix creates missing patrol molecules. +func (c *PatrolMoleculesExistCheck) Fix(ctx *CheckContext) error { + for rigName, missing := range c.missingMols { + rigPath := filepath.Join(ctx.TownRoot, rigName) + for _, mol := range missing { + desc := getPatrolMoleculeDesc(mol) + cmd := exec.Command("bd", "create", + "--type=molecule", + "--title="+mol, + "--description="+desc, + "--priority=2", + ) + cmd.Dir = rigPath + if err := cmd.Run(); err != nil { + return fmt.Errorf("creating %s in %s: %w", mol, rigName, err) + } + } + } + return nil +} + +func getPatrolMoleculeDesc(title string) string { + switch title { + case "Deacon Patrol": + return "Mayor's daemon patrol loop for handling callbacks, health checks, and cleanup." + case "Witness Patrol": + return "Per-rig worker monitor patrol loop with progressive nudging." + case "Refinery Patrol": + return "Merge queue processor patrol loop with verification gates." + default: + return "Patrol molecule" + } +} + +// PatrolHooksWiredCheck verifies that hooks trigger patrol execution. +type PatrolHooksWiredCheck struct { + BaseCheck +} + +// NewPatrolHooksWiredCheck creates a new patrol hooks wired check. +func NewPatrolHooksWiredCheck() *PatrolHooksWiredCheck { + return &PatrolHooksWiredCheck{ + BaseCheck: BaseCheck{ + CheckName: "patrol-hooks-wired", + CheckDescription: "Check if hooks trigger patrol execution", + }, + } +} + +// Run checks if patrol hooks are wired. +func (c *PatrolHooksWiredCheck) Run(ctx *CheckContext) *CheckResult { + // Check for daemon config which manages patrols + daemonConfigPath := filepath.Join(ctx.TownRoot, "mayor", "daemon.json") + if _, err := os.Stat(daemonConfigPath); os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Daemon config not found", + FixHint: "Run 'gt daemon init' to configure daemon", + } + } + + // Check daemon config for patrol configuration + data, err := os.ReadFile(daemonConfigPath) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to read daemon config", + Details: []string{err.Error()}, + } + } + + var config map[string]interface{} + if err := json.Unmarshal(data, &config); err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Invalid daemon config format", + Details: []string{err.Error()}, + } + } + + // Check for patrol entries + if patrols, ok := config["patrols"]; ok { + if patrolMap, ok := patrols.(map[string]interface{}); ok && len(patrolMap) > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("Daemon configured with %d patrol(s)", len(patrolMap)), + } + } + } + + // Check if heartbeat is enabled (triggers deacon patrol) + if heartbeat, ok := config["heartbeat"]; ok { + if hb, ok := heartbeat.(map[string]interface{}); ok { + if enabled, ok := hb["enabled"].(bool); ok && enabled { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Daemon heartbeat enabled (triggers patrols)", + } + } + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Patrol hooks not configured in daemon", + FixHint: "Configure patrols in mayor/daemon.json or run 'gt daemon init'", + } +} + +// PatrolNotStuckCheck detects wisps that have been in_progress too long. +type PatrolNotStuckCheck struct { + BaseCheck + stuckThreshold time.Duration +} + +// NewPatrolNotStuckCheck creates a new patrol not stuck check. +func NewPatrolNotStuckCheck() *PatrolNotStuckCheck { + return &PatrolNotStuckCheck{ + BaseCheck: BaseCheck{ + CheckName: "patrol-not-stuck", + CheckDescription: "Check for stuck patrol wisps (>1h in_progress)", + }, + stuckThreshold: 1 * time.Hour, + } +} + +// Run checks for stuck patrol wisps. +func (c *PatrolNotStuckCheck) Run(ctx *CheckContext) *CheckResult { + rigs, err := discoverRigs(ctx.TownRoot) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to discover rigs", + Details: []string{err.Error()}, + } + } + + if len(rigs) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rigs configured", + } + } + + var stuckWisps []string + for _, rigName := range rigs { + wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp", "issues.jsonl") + stuck := c.checkStuckWisps(wispPath, rigName) + stuckWisps = append(stuckWisps, stuck...) + } + + if len(stuckWisps) > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d stuck patrol wisp(s) found (>1h)", len(stuckWisps)), + Details: stuckWisps, + FixHint: "Manual review required - wisps may need to be burned or sessions restarted", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No stuck patrol wisps found", + } +} + +// checkStuckWisps returns descriptions of stuck wisps in a rig. +func (c *PatrolNotStuckCheck) checkStuckWisps(issuesPath string, rigName string) []string { + file, err := os.Open(issuesPath) + if err != nil { + return nil // No issues file + } + defer file.Close() + + var stuck []string + cutoff := time.Now().Add(-c.stuckThreshold) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var issue struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + UpdatedAt time.Time `json:"updated_at"` + } + if err := json.Unmarshal([]byte(line), &issue); err != nil { + continue + } + + // Check for in_progress issues older than threshold + if issue.Status == "in_progress" && !issue.UpdatedAt.IsZero() && issue.UpdatedAt.Before(cutoff) { + stuck = append(stuck, fmt.Sprintf("%s: %s (%s) - stale since %s", + rigName, issue.ID, issue.Title, issue.UpdatedAt.Format("2006-01-02 15:04"))) + } + } + + return stuck +} + +// PatrolPluginsAccessibleCheck verifies plugin directories exist and are readable. +type PatrolPluginsAccessibleCheck struct { + FixableCheck + missingDirs []string +} + +// NewPatrolPluginsAccessibleCheck creates a new patrol plugins accessible check. +func NewPatrolPluginsAccessibleCheck() *PatrolPluginsAccessibleCheck { + return &PatrolPluginsAccessibleCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "patrol-plugins-accessible", + CheckDescription: "Check if plugin directories exist and are readable", + }, + }, + } +} + +// Run checks if plugin directories are accessible. +func (c *PatrolPluginsAccessibleCheck) Run(ctx *CheckContext) *CheckResult { + c.missingDirs = nil + + // Check town-level plugins directory + townPluginsDir := filepath.Join(ctx.TownRoot, "plugins") + if _, err := os.Stat(townPluginsDir); os.IsNotExist(err) { + c.missingDirs = append(c.missingDirs, townPluginsDir) + } + + // Check rig-level plugins directories + rigs, err := discoverRigs(ctx.TownRoot) + if err == nil { + for _, rigName := range rigs { + rigPluginsDir := filepath.Join(ctx.TownRoot, rigName, "plugins") + if _, err := os.Stat(rigPluginsDir); os.IsNotExist(err) { + c.missingDirs = append(c.missingDirs, rigPluginsDir) + } + } + } + + if len(c.missingDirs) > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d plugin directory(ies) missing", len(c.missingDirs)), + Details: c.missingDirs, + FixHint: "Run 'gt doctor --fix' to create missing directories", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "All plugin directories accessible", + } +} + +// Fix creates missing plugin directories. +func (c *PatrolPluginsAccessibleCheck) Fix(ctx *CheckContext) error { + for _, dir := range c.missingDirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + } + return nil +} + +// PatrolRolesHavePromptsCheck verifies that prompts/roles/*.md exist for each role. +type PatrolRolesHavePromptsCheck struct { + BaseCheck +} + +// NewPatrolRolesHavePromptsCheck creates a new patrol roles have prompts check. +func NewPatrolRolesHavePromptsCheck() *PatrolRolesHavePromptsCheck { + return &PatrolRolesHavePromptsCheck{ + BaseCheck: BaseCheck{ + CheckName: "patrol-roles-have-prompts", + CheckDescription: "Check if prompts/roles/*.md exist for each patrol role", + }, + } +} + +// requiredRolePrompts are the required role prompt files. +var requiredRolePrompts = []string{ + "deacon.md", + "witness.md", + "refinery.md", +} + +// Run checks if role prompts exist. +func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult { + rigs, err := discoverRigs(ctx.TownRoot) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to discover rigs", + Details: []string{err.Error()}, + } + } + + if len(rigs) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rigs configured", + } + } + + var missingPrompts []string + for _, rigName := range rigs { + // Check in mayor's clone (canonical for the rig) + mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig") + promptsDir := filepath.Join(mayorRig, "prompts", "roles") + + for _, roleFile := range requiredRolePrompts { + promptPath := filepath.Join(promptsDir, roleFile) + if _, err := os.Stat(promptPath); os.IsNotExist(err) { + missingPrompts = append(missingPrompts, fmt.Sprintf("%s: %s", rigName, roleFile)) + } + } + } + + if len(missingPrompts) > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d role prompt(s) missing", len(missingPrompts)), + Details: missingPrompts, + FixHint: "Role prompts should be in the project repository under prompts/roles/", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "All patrol role prompts found", + } +} diff --git a/internal/rig/manager.go b/internal/rig/manager.go index 62e5bd18..841c9930 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -283,6 +283,18 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { return nil, fmt.Errorf("initializing wisp beads: %w", err) } + // Seed patrol molecules for this rig + if err := m.seedPatrolMolecules(rigPath); err != nil { + // Non-fatal: log warning but continue + fmt.Printf(" Warning: Could not seed patrol molecules: %v\n", err) + } + + // Create plugin directories + if err := m.createPluginDirectories(rigPath); err != nil { + // Non-fatal: log warning but continue + fmt.Printf(" Warning: Could not create plugin directories: %v\n", err) + } + // Register in town config m.config.Rigs[opts.Name] = config.RigEntry{ GitURL: opts.GitURL, @@ -491,3 +503,112 @@ func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName claudePath := filepath.Join(workspacePath, "CLAUDE.md") return os.WriteFile(claudePath, []byte(content), 0644) } + +// seedPatrolMolecules creates patrol molecule prototypes in the rig's beads database. +// These molecules define the work loops for Deacon, Witness, and Refinery roles. +func (m *Manager) seedPatrolMolecules(rigPath string) error { + // Use bd command to seed molecules (more reliable than internal API) + // The bd mol seed command creates built-in molecules if they don't exist + cmd := exec.Command("bd", "mol", "seed", "--patrol") + cmd.Dir = rigPath + if err := cmd.Run(); err != nil { + // Fallback: bd mol seed might not support --patrol yet + // Try creating them individually via bd create + return m.seedPatrolMoleculesManually(rigPath) + } + return nil +} + +// seedPatrolMoleculesManually creates patrol molecules using bd create commands. +func (m *Manager) seedPatrolMoleculesManually(rigPath string) error { + // Patrol molecule definitions (subset of builtin_molecules.go for seeding) + patrolMols := []struct { + title string + desc string + }{ + { + title: "Deacon Patrol", + desc: "Mayor's daemon patrol loop for handling callbacks, health checks, and cleanup.", + }, + { + title: "Witness Patrol", + desc: "Per-rig worker monitor patrol loop with progressive nudging.", + }, + { + title: "Refinery Patrol", + desc: "Merge queue processor patrol loop with verification gates.", + }, + } + + for _, mol := range patrolMols { + // Check if already exists by title + checkCmd := exec.Command("bd", "list", "--type=molecule", "--format=json") + checkCmd.Dir = rigPath + output, _ := checkCmd.Output() + if strings.Contains(string(output), mol.title) { + continue // Already exists + } + + // Create the molecule + cmd := exec.Command("bd", "create", + "--type=molecule", + "--title="+mol.title, + "--description="+mol.desc, + "--priority=2", + ) + cmd.Dir = rigPath + if err := cmd.Run(); err != nil { + // Non-fatal, continue with others + continue + } + } + return nil +} + +// createPluginDirectories creates plugin directories at town and rig levels. +// - ~/gt/plugins/ (town-level, shared across all rigs) +// - /plugins/ (rig-level, rig-specific plugins) +func (m *Manager) createPluginDirectories(rigPath string) error { + // Town-level plugins directory + townPluginsDir := filepath.Join(m.townRoot, "plugins") + if err := os.MkdirAll(townPluginsDir, 0755); err != nil { + return fmt.Errorf("creating town plugins directory: %w", err) + } + + // Create a README in town plugins if it doesn't exist + townReadme := filepath.Join(townPluginsDir, "README.md") + if _, err := os.Stat(townReadme); os.IsNotExist(err) { + content := `# Gas Town Plugins + +This directory contains town-level plugins that run during Deacon patrol cycles. + +## Plugin Structure + +Each plugin is a directory containing: +- plugin.md - Plugin definition with YAML frontmatter + +## Gate Types + +- cooldown: Time since last run (e.g., 24h) +- cron: Schedule-based (e.g., "0 9 * * *") +- condition: Metric threshold +- event: Trigger-based (startup, heartbeat) + +See docs/deacon-plugins.md for full documentation. +` + if writeErr := os.WriteFile(townReadme, []byte(content), 0644); writeErr != nil { + // Non-fatal + return nil + } + } + + // Rig-level plugins directory + rigPluginsDir := filepath.Join(rigPath, "plugins") + if err := os.MkdirAll(rigPluginsDir, 0755); err != nil { + return fmt.Errorf("creating rig plugins directory: %w", err) + } + + // Add plugins/ to rig .gitignore + gitignorePath := filepath.Join(rigPath, ".gitignore") + return m.ensureGitignoreEntry(gitignorePath, "plugins/") +}