package rig import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/templates" ) // Common errors var ( ErrRigNotFound = errors.New("rig not found") ErrRigExists = errors.New("rig already exists") ) // RigConfig represents the rig-level configuration (config.json at rig root). type RigConfig struct { Type string `json:"type"` // "rig" Version int `json:"version"` // schema version Name string `json:"name"` // rig name GitURL string `json:"git_url"` // repository URL CreatedAt time.Time `json:"created_at"` // when rig was created Beads *BeadsConfig `json:"beads,omitempty"` } // BeadsConfig represents beads configuration for the rig. type BeadsConfig struct { Prefix string `json:"prefix"` // issue prefix (e.g., "gt") SyncRemote string `json:"sync_remote,omitempty"` // git remote for bd sync } // CurrentRigConfigVersion is the current schema version. const CurrentRigConfigVersion = 1 // Manager handles rig discovery, loading, and creation. type Manager struct { townRoot string config *config.RigsConfig git *git.Git } // NewManager creates a new rig manager. func NewManager(townRoot string, rigsConfig *config.RigsConfig, g *git.Git) *Manager { return &Manager{ townRoot: townRoot, config: rigsConfig, git: g, } } // DiscoverRigs returns all rigs registered in the workspace. func (m *Manager) DiscoverRigs() ([]*Rig, error) { var rigs []*Rig for name, entry := range m.config.Rigs { rig, err := m.loadRig(name, entry) if err != nil { // Log error but continue with other rigs continue } rigs = append(rigs, rig) } return rigs, nil } // GetRig returns a specific rig by name. func (m *Manager) GetRig(name string) (*Rig, error) { entry, ok := m.config.Rigs[name] if !ok { return nil, ErrRigNotFound } return m.loadRig(name, entry) } // RigExists checks if a rig is registered. func (m *Manager) RigExists(name string) bool { _, ok := m.config.Rigs[name] return ok } // loadRig loads rig details from the filesystem. func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) { rigPath := filepath.Join(m.townRoot, name) // Verify directory exists info, err := os.Stat(rigPath) if err != nil { return nil, fmt.Errorf("rig directory: %w", err) } if !info.IsDir() { return nil, fmt.Errorf("not a directory: %s", rigPath) } rig := &Rig{ Name: name, Path: rigPath, GitURL: entry.GitURL, Config: entry.BeadsConfig, } // Scan for polecats polecatsDir := filepath.Join(rigPath, "polecats") if entries, err := os.ReadDir(polecatsDir); err == nil { for _, e := range entries { if e.IsDir() { rig.Polecats = append(rig.Polecats, e.Name()) } } } // Scan for crew workers crewDir := filepath.Join(rigPath, "crew") if entries, err := os.ReadDir(crewDir); err == nil { for _, e := range entries { if e.IsDir() { rig.Crew = append(rig.Crew, e.Name()) } } } // Check for witness (witnesses don't have clones, just state.json) witnessStatePath := filepath.Join(rigPath, "witness", "state.json") if _, err := os.Stat(witnessStatePath); err == nil { rig.HasWitness = true } // Check for refinery refineryPath := filepath.Join(rigPath, "refinery", "rig") if _, err := os.Stat(refineryPath); err == nil { rig.HasRefinery = true } // Check for mayor clone mayorPath := filepath.Join(rigPath, "mayor", "rig") if _, err := os.Stat(mayorPath); err == nil { rig.HasMayor = true } return rig, nil } // AddRigOptions configures rig creation. type AddRigOptions struct { Name string // Rig name (directory name) GitURL string // Repository URL BeadsPrefix string // Beads issue prefix (defaults to derived from name) CrewName string // Default crew workspace name (defaults to "main") } // AddRig creates a new rig as a container with clones for each agent. // The rig structure is: // // / # Container (NOT a git clone) // ├── config.json # Rig configuration // ├── .beads/ # Rig-level issue tracking // ├── refinery/rig/ # Canonical main clone // ├── mayor/rig/ # Mayor's working clone // ├── witness/ # Witness agent (no clone) // ├── polecats/ # Worker directories (empty) // └── crew// # Default human workspace func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { if m.RigExists(opts.Name) { return nil, ErrRigExists } rigPath := filepath.Join(m.townRoot, opts.Name) // Check if directory already exists if _, err := os.Stat(rigPath); err == nil { return nil, fmt.Errorf("directory already exists: %s", rigPath) } // Derive defaults if opts.BeadsPrefix == "" { opts.BeadsPrefix = deriveBeadsPrefix(opts.Name) } if opts.CrewName == "" { opts.CrewName = "main" } // Create container directory if err := os.MkdirAll(rigPath, 0755); err != nil { return nil, fmt.Errorf("creating rig directory: %w", err) } // Track cleanup on failure cleanup := func() { _ = os.RemoveAll(rigPath) } success := false defer func() { if !success { cleanup() } }() // Create rig config rigConfig := &RigConfig{ Type: "rig", Version: CurrentRigConfigVersion, Name: opts.Name, GitURL: opts.GitURL, CreatedAt: time.Now(), Beads: &BeadsConfig{ Prefix: opts.BeadsPrefix, }, } if err := m.saveRigConfig(rigPath, rigConfig); err != nil { return nil, fmt.Errorf("saving rig config: %w", err) } // Clone repository for refinery (canonical main) refineryRigPath := filepath.Join(rigPath, "refinery", "rig") if err := os.MkdirAll(filepath.Dir(refineryRigPath), 0755); err != nil { return nil, fmt.Errorf("creating refinery dir: %w", err) } if err := m.git.Clone(opts.GitURL, refineryRigPath); err != nil { return nil, fmt.Errorf("cloning for refinery: %w", err) } // Create refinery CLAUDE.md (overrides any from cloned repo) if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil { return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err) } // Clone repository for mayor mayorRigPath := filepath.Join(rigPath, "mayor", "rig") if err := os.MkdirAll(filepath.Dir(mayorRigPath), 0755); err != nil { return nil, fmt.Errorf("creating mayor dir: %w", err) } if err := m.git.Clone(opts.GitURL, mayorRigPath); err != nil { return nil, fmt.Errorf("cloning for mayor: %w", err) } // Create mayor CLAUDE.md (overrides any from cloned repo) if err := m.createRoleCLAUDEmd(mayorRigPath, "mayor", opts.Name, ""); err != nil { return nil, fmt.Errorf("creating mayor CLAUDE.md: %w", err) } // Clone repository for default crew workspace crewPath := filepath.Join(rigPath, "crew", opts.CrewName) if err := os.MkdirAll(filepath.Dir(crewPath), 0755); err != nil { return nil, fmt.Errorf("creating crew dir: %w", err) } if err := m.git.Clone(opts.GitURL, crewPath); err != nil { return nil, fmt.Errorf("cloning for crew: %w", err) } // Create crew CLAUDE.md (overrides any from cloned repo) if err := m.createRoleCLAUDEmd(crewPath, "crew", opts.Name, opts.CrewName); err != nil { return nil, fmt.Errorf("creating crew CLAUDE.md: %w", err) } // Create witness directory (no clone needed) witnessPath := filepath.Join(rigPath, "witness") if err := os.MkdirAll(witnessPath, 0755); err != nil { return nil, fmt.Errorf("creating witness dir: %w", err) } // Create polecats directory (empty) polecatsPath := filepath.Join(rigPath, "polecats") if err := os.MkdirAll(polecatsPath, 0755); err != nil { return nil, fmt.Errorf("creating polecats dir: %w", err) } // Initialize agent state files if err := m.initAgentStates(rigPath); err != nil { return nil, fmt.Errorf("initializing agent states: %w", err) } // Initialize beads at rig level if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil { return nil, fmt.Errorf("initializing beads: %w", err) } // Initialize wisp beads for wisp/molecule tracking if err := m.initWispBeads(rigPath); err != nil { 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, AddedAt: time.Now(), BeadsConfig: &config.BeadsConfig{ Prefix: opts.BeadsPrefix, }, } success = true return m.loadRig(opts.Name, m.config.Rigs[opts.Name]) } // saveRigConfig writes the rig configuration to config.json. func (m *Manager) saveRigConfig(rigPath string, cfg *RigConfig) error { configPath := filepath.Join(rigPath, "config.json") data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(configPath, data, 0644) } // initAgentStates creates initial state.json files for agents. func (m *Manager) initAgentStates(rigPath string) error { agents := []struct { path string role string }{ {filepath.Join(rigPath, "refinery", "state.json"), "refinery"}, {filepath.Join(rigPath, "witness", "state.json"), "witness"}, {filepath.Join(rigPath, "mayor", "state.json"), "mayor"}, } for _, agent := range agents { state := &config.AgentState{ Role: agent.role, LastActive: time.Now(), } data, err := json.MarshalIndent(state, "", " ") if err != nil { return err } if err := os.WriteFile(agent.path, data, 0644); err != nil { return err } } return nil } // initBeads initializes the beads database at rig level. // The project's .beads/config.yaml determines sync-branch settings. // Use `bd doctor --fix` in the project to configure sync-branch if needed. func (m *Manager) initBeads(rigPath, prefix string) error { beadsDir := filepath.Join(rigPath, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { return err } // Run bd init if available, with --no-agents to skip AGENTS.md creation cmd := exec.Command("bd", "init", "--prefix", prefix, "--no-agents") cmd.Dir = rigPath if err := cmd.Run(); err != nil { // bd might not be installed or --no-agents not supported, create minimal structure configPath := filepath.Join(beadsDir, "config.yaml") configContent := fmt.Sprintf("prefix: %s\n", prefix) if writeErr := os.WriteFile(configPath, []byte(configContent), 0644); writeErr != nil { return writeErr } } return nil } // initWispBeads initializes the wisp beads database at rig level. // Wisp beads are local-only (no sync-branch) and used for runtime tracking // of wisps and molecules. func (m *Manager) initWispBeads(rigPath string) error { beadsDir := filepath.Join(rigPath, ".beads-wisp") if err := os.MkdirAll(beadsDir, 0755); err != nil { return err } // Initialize as a git repo (for local versioning, not for sync) cmd := exec.Command("git", "init") cmd.Dir = beadsDir if err := cmd.Run(); err != nil { return fmt.Errorf("git init: %w", err) } // Create wisp config (no sync-branch needed) configPath := filepath.Join(beadsDir, "config.yaml") configContent := "wisp: true\n# No sync-branch - wisp is local only\n" if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { return err } // Add .beads-wisp/ to .gitignore if not already present gitignorePath := filepath.Join(rigPath, ".gitignore") return m.ensureGitignoreEntry(gitignorePath, ".beads-wisp/") } // ensureGitignoreEntry adds an entry to .gitignore if it doesn't already exist. func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error { // Read existing content content, err := os.ReadFile(gitignorePath) if err != nil && !os.IsNotExist(err) { return err } // Check if entry already exists lines := strings.Split(string(content), "\n") for _, line := range lines { if strings.TrimSpace(line) == entry { return nil // Already present } } // Append entry f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } defer f.Close() // Add newline before if file doesn't end with one if len(content) > 0 && content[len(content)-1] != '\n' { if _, err := f.WriteString("\n"); err != nil { return err } } _, err = f.WriteString(entry + "\n") return err } // deriveBeadsPrefix generates a beads prefix from a rig name. // Examples: "gastown" -> "gt", "my-project" -> "mp", "foo" -> "foo" func deriveBeadsPrefix(name string) string { // Remove common suffixes name = strings.TrimSuffix(name, "-py") name = strings.TrimSuffix(name, "-go") // Split on hyphens/underscores parts := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' }) if len(parts) >= 2 { // Take first letter of each part: "gas-town" -> "gt" prefix := "" for _, p := range parts { if len(p) > 0 { prefix += string(p[0]) } } return strings.ToLower(prefix) } // Single word: use first 2-3 chars if len(name) <= 3 { return strings.ToLower(name) } return strings.ToLower(name[:2]) } // RemoveRig unregisters a rig (does not delete files). func (m *Manager) RemoveRig(name string) error { if !m.RigExists(name) { return ErrRigNotFound } delete(m.config.Rigs, name) return nil } // ListRigNames returns the names of all registered rigs. func (m *Manager) ListRigNames() []string { names := make([]string, 0, len(m.config.Rigs)) for name := range m.config.Rigs { names = append(names, name) } return names } // createRoleCLAUDEmd creates a CLAUDE.md file with role-specific context. // This ensures each workspace (crew, refinery, mayor) gets the correct prompting, // overriding any CLAUDE.md that may exist in the cloned repository. func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName string, workerName string) error { tmpl, err := templates.New() if err != nil { return err } data := templates.RoleData{ Role: role, RigName: rigName, TownRoot: m.townRoot, WorkDir: workspacePath, Polecat: workerName, // Used for crew member name as well } content, err := tmpl.RenderRole(role, data) if err != nil { return err } 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/") }