package crew import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "time" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/templates" ) // Common errors var ( ErrCrewExists = errors.New("crew worker already exists") ErrCrewNotFound = errors.New("crew worker not found") ErrHasChanges = errors.New("crew worker has uncommitted changes") ) // Manager handles crew worker lifecycle. type Manager struct { rig *rig.Rig git *git.Git } // NewManager creates a new crew manager. func NewManager(r *rig.Rig, g *git.Git) *Manager { return &Manager{ rig: r, git: g, } } // crewDir returns the directory for a crew worker. func (m *Manager) crewDir(name string) string { return filepath.Join(m.rig.Path, "crew", name) } // stateFile returns the state file path for a crew worker. func (m *Manager) stateFile(name string) string { return filepath.Join(m.crewDir(name), "state.json") } // mailDir returns the mail directory path for a crew worker. func (m *Manager) mailDir(name string) string { return filepath.Join(m.crewDir(name), "mail") } // exists checks if a crew worker exists. func (m *Manager) exists(name string) bool { _, err := os.Stat(m.crewDir(name)) return err == nil } // Add creates a new crew worker with a clone of the rig. func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) { if m.exists(name) { return nil, ErrCrewExists } crewPath := m.crewDir(name) // Create crew directory if needed crewBaseDir := filepath.Join(m.rig.Path, "crew") if err := os.MkdirAll(crewBaseDir, 0755); err != nil { return nil, fmt.Errorf("creating crew dir: %w", err) } // Clone the rig repo if err := m.git.Clone(m.rig.GitURL, crewPath); err != nil { return nil, fmt.Errorf("cloning rig: %w", err) } crewGit := git.NewGit(crewPath) branchName := "main" // Optionally create a working branch if createBranch { branchName = fmt.Sprintf("crew/%s", name) if err := crewGit.CreateBranch(branchName); err != nil { _ = os.RemoveAll(crewPath) return nil, fmt.Errorf("creating branch: %w", err) } if err := crewGit.Checkout(branchName); err != nil { _ = os.RemoveAll(crewPath) return nil, fmt.Errorf("checking out branch: %w", err) } } // Create mail directory for mail delivery mailPath := m.mailDir(name) if err := os.MkdirAll(mailPath, 0755); err != nil { _ = os.RemoveAll(crewPath) return nil, fmt.Errorf("creating mail dir: %w", err) } // Set up shared beads: crew uses rig's shared beads via redirect file if err := m.setupSharedBeads(crewPath); err != nil { // Non-fatal - crew can still work, warn but don't fail fmt.Printf("Warning: could not set up shared beads: %v\n", err) } // Create CLAUDE.md with crew worker prompting if err := m.createClaudeMD(name, crewPath); err != nil { _ = os.RemoveAll(crewPath) return nil, fmt.Errorf("creating CLAUDE.md: %w", err) } // Create crew worker state now := time.Now() crew := &CrewWorker{ Name: name, Rig: m.rig.Name, ClonePath: crewPath, Branch: branchName, CreatedAt: now, UpdatedAt: now, } // Save state if err := m.saveState(crew); err != nil { _ = os.RemoveAll(crewPath) return nil, fmt.Errorf("saving state: %w", err) } return crew, nil } // createClaudeMD creates the CLAUDE.md file for crew worker prompting. // Uses the crew template from internal/templates for comprehensive context. func (m *Manager) createClaudeMD(name, crewPath string) error { // Try to use templates for comprehensive crew context tmpl, err := templates.New() if err != nil { // Fall back to minimal content if templates fail return m.createClaudeMDFallback(name, crewPath) } // Find town root by walking up from rig path townRoot := filepath.Dir(m.rig.Path) // Build template data data := templates.RoleData{ Role: "crew", RigName: m.rig.Name, TownRoot: townRoot, WorkDir: crewPath, Polecat: name, // Used for crew member name } // Render the crew template content, err := tmpl.RenderRole("crew", data) if err != nil { // Fall back if rendering fails return m.createClaudeMDFallback(name, crewPath) } claudePath := filepath.Join(crewPath, "CLAUDE.md") return os.WriteFile(claudePath, []byte(content), 0644) } // createClaudeMDFallback creates a minimal CLAUDE.md if templates fail. func (m *Manager) createClaudeMDFallback(name, crewPath string) error { content := fmt.Sprintf(`# Claude: Crew Worker - %s Run `+"`gt prime`"+` for full crew worker context. You are a **crew worker** in the %s rig. Crew workers are user-managed persistent workspaces. ## Key Differences from Polecats - **User-managed**: You are NOT managed by the Witness daemon - **Persistent**: Your workspace is not automatically cleaned up - **Long-lived identity**: You keep your name across sessions - **Mail enabled**: You can send and receive mail ## Key Commands - `+"`gt prime`"+` - Output full crew worker context - `+"`gt mail inbox`"+` - Check your inbox - `+"`bd ready`"+` - Available issues (if beads configured) - `+"`bd show `"+` - View issue details - `+"`bd close `"+` - Mark issue complete Crew: %s | Rig: %s `, name, m.rig.Name, name, m.rig.Name) claudePath := filepath.Join(crewPath, "CLAUDE.md") return os.WriteFile(claudePath, []byte(content), 0644) } // Remove deletes a crew worker. func (m *Manager) Remove(name string, force bool) error { if !m.exists(name) { return ErrCrewNotFound } crewPath := m.crewDir(name) if !force { crewGit := git.NewGit(crewPath) hasChanges, err := crewGit.HasUncommittedChanges() if err == nil && hasChanges { return ErrHasChanges } } // Remove directory if err := os.RemoveAll(crewPath); err != nil { return fmt.Errorf("removing crew dir: %w", err) } return nil } // List returns all crew workers in the rig. func (m *Manager) List() ([]*CrewWorker, error) { crewBaseDir := filepath.Join(m.rig.Path, "crew") entries, err := os.ReadDir(crewBaseDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("reading crew dir: %w", err) } var workers []*CrewWorker for _, entry := range entries { if !entry.IsDir() { continue } worker, err := m.Get(entry.Name()) if err != nil { continue // Skip invalid workers } workers = append(workers, worker) } return workers, nil } // Get returns a specific crew worker by name. func (m *Manager) Get(name string) (*CrewWorker, error) { if !m.exists(name) { return nil, ErrCrewNotFound } return m.loadState(name) } // saveState persists crew worker state to disk. func (m *Manager) saveState(crew *CrewWorker) error { data, err := json.MarshalIndent(crew, "", " ") if err != nil { return fmt.Errorf("marshaling state: %w", err) } stateFile := m.stateFile(crew.Name) if err := os.WriteFile(stateFile, data, 0644); err != nil { return fmt.Errorf("writing state: %w", err) } return nil } // loadState reads crew worker state from disk. func (m *Manager) loadState(name string) (*CrewWorker, error) { stateFile := m.stateFile(name) data, err := os.ReadFile(stateFile) if err != nil { if os.IsNotExist(err) { // Return minimal crew worker if state file missing return &CrewWorker{ Name: name, Rig: m.rig.Name, ClonePath: m.crewDir(name), }, nil } return nil, fmt.Errorf("reading state: %w", err) } var crew CrewWorker if err := json.Unmarshal(data, &crew); err != nil { return nil, fmt.Errorf("parsing state: %w", err) } // Backfill essential fields if missing (handles empty or incomplete state.json) if crew.Name == "" { crew.Name = name } if crew.Rig == "" { crew.Rig = m.rig.Name } if crew.ClonePath == "" { crew.ClonePath = m.crewDir(name) } return &crew, nil } // Rename renames a crew worker from oldName to newName. func (m *Manager) Rename(oldName, newName string) error { if !m.exists(oldName) { return ErrCrewNotFound } if m.exists(newName) { return ErrCrewExists } oldPath := m.crewDir(oldName) newPath := m.crewDir(newName) // Rename directory if err := os.Rename(oldPath, newPath); err != nil { return fmt.Errorf("renaming crew dir: %w", err) } // Update state file with new name and path crew, err := m.loadState(newName) if err != nil { // Rollback on error _ = os.Rename(newPath, oldPath) return fmt.Errorf("loading state: %w", err) } crew.Name = newName crew.ClonePath = newPath crew.UpdatedAt = time.Now() if err := m.saveState(crew); err != nil { // Rollback on error _ = os.Rename(newPath, oldPath) return fmt.Errorf("saving state: %w", err) } return nil } // Pristine ensures a crew worker is up-to-date with remote. // It runs git pull --rebase and bd sync. func (m *Manager) Pristine(name string) (*PristineResult, error) { if !m.exists(name) { return nil, ErrCrewNotFound } crewPath := m.crewDir(name) crewGit := git.NewGit(crewPath) result := &PristineResult{ Name: name, } // Check for uncommitted changes hasChanges, err := crewGit.HasUncommittedChanges() if err != nil { return nil, fmt.Errorf("checking changes: %w", err) } result.HadChanges = hasChanges // Pull latest (use origin and current branch) if err := crewGit.Pull("origin", ""); err != nil { result.PullError = err.Error() } else { result.Pulled = true } // Run bd sync if err := m.runBdSync(crewPath); err != nil { result.SyncError = err.Error() } else { result.Synced = true } return result, nil } // runBdSync runs bd sync in the given directory. func (m *Manager) runBdSync(dir string) error { cmd := exec.Command("bd", "sync") cmd.Dir = dir return cmd.Run() } // PristineResult captures the results of a pristine operation. type PristineResult struct { Name string `json:"name"` HadChanges bool `json:"had_changes"` Pulled bool `json:"pulled"` PullError string `json:"pull_error,omitempty"` Synced bool `json:"synced"` SyncError string `json:"sync_error,omitempty"` } // setupSharedBeads creates a redirect file so the crew worker uses the rig's shared .beads database. // This eliminates the need for git sync between crew clones - all crew members share one database. // // Structure: // // rig/ // mayor/rig/.beads/ <- Shared database (the canonical location) // crew/ // / // .beads/ // redirect <- Contains "../../mayor/rig/.beads" func (m *Manager) setupSharedBeads(crewPath string) error { // The shared beads database is at rig/mayor/rig/.beads/ // Crew clones are at rig/crew// // So the relative path is ../../mayor/rig/.beads sharedBeadsPath := filepath.Join(m.rig.Path, "mayor", "rig", ".beads") // Verify the shared beads exists if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) { // Fall back to rig root .beads if mayor/rig doesn't exist sharedBeadsPath = filepath.Join(m.rig.Path, ".beads") if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) { return fmt.Errorf("no shared beads database found") } } // Create crew's .beads directory crewBeadsDir := filepath.Join(crewPath, ".beads") if err := os.MkdirAll(crewBeadsDir, 0755); err != nil { return fmt.Errorf("creating crew .beads dir: %w", err) } // Calculate relative path from crew/.beads/ to shared beads // crew//.beads/ -> ../../mayor/rig/.beads or ../../.beads var redirectContent string if _, err := os.Stat(filepath.Join(m.rig.Path, "mayor", "rig", ".beads")); err == nil { redirectContent = "../../mayor/rig/.beads\n" } else { redirectContent = "../../.beads\n" } // Create redirect file redirectPath := filepath.Join(crewBeadsDir, "redirect") if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil { return fmt.Errorf("creating redirect file: %w", err) } return nil }