Merge gt-cik.2-furiosa: add gt crew add command

This commit is contained in:
Steve Yegge
2025-12-16 20:51:52 -08:00
4 changed files with 554 additions and 337 deletions

156
internal/cmd/crew.go Normal file
View File

@@ -0,0 +1,156 @@
package cmd
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// Crew command flags
var (
crewRig string
crewBranch bool
)
var crewCmd = &cobra.Command{
Use: "crew",
Short: "Manage crew workspaces (user-managed persistent workspaces)",
Long: `Crew workers are user-managed persistent workspaces within a rig.
Unlike polecats which are witness-managed and ephemeral, crew workers are:
- Persistent: Not auto-garbage-collected
- User-managed: Overseer controls lifecycle
- Long-lived identities: recognizable names like dave, emma, fred
- Gas Town integrated: Mail, handoff mechanics work
- Tmux optional: Can work in terminal directly
Commands:
gt crew add <name> Create a new crew workspace
gt crew list List crew workspaces
gt crew remove <name> Remove a crew workspace`,
}
var crewAddCmd = &cobra.Command{
Use: "add <name>",
Short: "Create a new crew workspace",
Long: `Create a new crew workspace with a clone of the rig repository.
The workspace is created at <rig>/crew/<name>/ with:
- A full git clone of the project repository
- Mail directory for message delivery
- CLAUDE.md with crew worker prompting
- Optional feature branch (crew/<name>)
Examples:
gt crew add dave # Create in current rig
gt crew add emma --rig gastown # Create in specific rig
gt crew add fred --branch # Create with feature branch`,
Args: cobra.ExactArgs(1),
RunE: runCrewAdd,
}
func init() {
crewAddCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to create crew workspace in")
crewAddCmd.Flags().BoolVar(&crewBranch, "branch", false, "Create a feature branch (crew/<name>)")
crewCmd.AddCommand(crewAddCmd)
rootCmd.AddCommand(crewCmd)
}
func runCrewAdd(cmd *cobra.Command, args []string) error {
name := args[0]
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
}
// Determine rig
rigName := crewRig
if rigName == "" {
// Try to infer from cwd
rigName, err = inferRigFromCwd(townRoot)
if err != nil {
return fmt.Errorf("could not determine rig (use --rig flag): %w", err)
}
}
// Get rig
g := git.NewGit(townRoot)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
r, err := rigMgr.GetRig(rigName)
if err != nil {
return fmt.Errorf("rig '%s' not found", rigName)
}
// Create crew manager
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
// Create crew workspace
fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName)
worker, err := crewMgr.Add(name, crewBranch)
if err != nil {
if err == crew.ErrCrewExists {
return fmt.Errorf("crew workspace '%s' already exists", name)
}
return fmt.Errorf("creating crew workspace: %w", err)
}
fmt.Printf("%s Created crew workspace: %s/%s\n",
style.Bold.Render("✓"), rigName, name)
fmt.Printf(" Path: %s\n", worker.ClonePath)
fmt.Printf(" Branch: %s\n", worker.Branch)
fmt.Printf(" Mail: %s/mail/\n", worker.ClonePath)
fmt.Printf("\n%s\n", style.Dim.Render("Start working with: cd "+worker.ClonePath))
return nil
}
// inferRigFromCwd tries to determine the rig from the current directory.
func inferRigFromCwd(townRoot string) (string, error) {
cwd, err := filepath.Abs(".")
if err != nil {
return "", err
}
// Check if cwd is within a rig
rel, err := filepath.Rel(townRoot, cwd)
if err != nil {
return "", fmt.Errorf("not in workspace")
}
// First component should be the rig name
parts := filepath.SplitList(rel)
if len(parts) == 0 {
// Split on path separator instead
for i := 0; i < len(rel); i++ {
if rel[i] == filepath.Separator {
return rel[:i], nil
}
}
// No separator found, entire rel is the rig name
if rel != "" && rel != "." {
return rel, nil
}
}
return "", fmt.Errorf("could not infer rig from current directory")
}

View File

@@ -14,9 +14,9 @@ import (
// Common errors
var (
ErrWorkerExists = errors.New("crew worker already exists")
ErrWorkerNotFound = errors.New("crew worker not found")
ErrHasChanges = errors.New("crew worker has uncommitted changes")
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.
@@ -33,121 +33,176 @@ func NewManager(r *rig.Rig, g *git.Git) *Manager {
}
}
// workerDir returns the directory for a crew worker.
func (m *Manager) workerDir(name string) string {
// 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.workerDir(name), "state.json")
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.workerDir(name))
_, 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) (*Worker, error) {
func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) {
if m.exists(name) {
return nil, ErrWorkerExists
return nil, ErrCrewExists
}
workerPath := m.workerDir(name)
crewPath := m.crewDir(name)
// Create crew directory if needed
crewDir := filepath.Join(m.rig.Path, "crew")
if err := os.MkdirAll(crewDir, 0755); err != nil {
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, workerPath); err != nil {
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)
}
// 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()
worker := &Worker{
crew := &CrewWorker{
Name: name,
Rig: m.rig.Name,
State: StateActive,
ClonePath: workerPath,
ClonePath: crewPath,
Branch: branchName,
CreatedAt: now,
UpdatedAt: now,
}
// Save state
if err := m.saveState(worker); err != nil {
os.RemoveAll(workerPath)
if err := m.saveState(crew); err != nil {
os.RemoveAll(crewPath)
return nil, fmt.Errorf("saving state: %w", err)
}
return worker, nil
return crew, nil
}
// AddWithConfig creates a new crew worker with custom configuration.
func (m *Manager) AddWithConfig(name string, beadsDir string) (*Worker, error) {
worker, err := m.Add(name)
if err != nil {
return nil, err
}
// createClaudeMD creates the CLAUDE.md file for crew worker prompting.
func (m *Manager) createClaudeMD(name, crewPath string) error {
content := fmt.Sprintf(`# Claude: Crew Worker - %s
// Update with custom config
if beadsDir != "" {
worker.BeadsDir = beadsDir
if err := m.saveState(worker); err != nil {
return nil, fmt.Errorf("saving config: %w", err)
}
}
You are a **crew worker** in the %s rig. Crew workers are user-managed persistent workspaces.
return worker, nil
## Key Differences from Polecats
- **User-managed**: You are NOT managed by the Witness daemon
- **Persistent**: Your workspace is not automatically cleaned up
- **Optional issue assignment**: You can work without a beads issue
- **Mail enabled**: You can send and receive mail
## Commands
Check mail:
`+"```bash"+`
town mail inbox --as %s/%s
`+"```"+`
Send mail:
`+"```bash"+`
town mail send <recipient> -s "Subject" -m "Message" --as %s/%s
`+"```"+`
## Session Cycling (Handoff)
When your context fills up, use mail-to-self for handoff:
1. Compose a handoff note with current state
2. Send to yourself: `+"```"+`town mail send %s/%s -s "Handoff" -m "..."--as %s/%s`+"```"+`
3. Exit cleanly
4. New session reads handoff from inbox
## Beads
If using beads for task tracking:
`+"```bash"+`
bd ready # Find available work
bd show <id> # Review issue details
bd update <id> --status=in_progress # Claim it
bd close <id> # Mark complete
`+"```"+`
`, name, m.rig.Name,
m.rig.Name, name,
m.rig.Name, name,
m.rig.Name, name, m.rig.Name, 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) error {
func (m *Manager) Remove(name string, force bool) error {
if !m.exists(name) {
return ErrWorkerNotFound
return ErrCrewNotFound
}
workerPath := m.workerDir(name)
workerGit := git.NewGit(workerPath)
crewPath := m.crewDir(name)
// Check for uncommitted changes
hasChanges, err := workerGit.HasUncommittedChanges()
if err == nil && hasChanges {
return ErrHasChanges
if !force {
crewGit := git.NewGit(crewPath)
hasChanges, err := crewGit.HasUncommittedChanges()
if err == nil && hasChanges {
return ErrHasChanges
}
}
// Remove directory
if err := os.RemoveAll(workerPath); err != nil {
return fmt.Errorf("removing crew worker dir: %w", err)
}
return nil
}
// RemoveForce deletes a crew worker even with uncommitted changes.
func (m *Manager) RemoveForce(name string) error {
if !m.exists(name) {
return ErrWorkerNotFound
}
workerPath := m.workerDir(name)
if err := os.RemoveAll(workerPath); err != nil {
return fmt.Errorf("removing crew worker dir: %w", err)
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() ([]*Worker, error) {
crewDir := filepath.Join(m.rig.Path, "crew")
func (m *Manager) List() ([]*CrewWorker, error) {
crewBaseDir := filepath.Join(m.rig.Path, "crew")
entries, err := os.ReadDir(crewDir)
entries, err := os.ReadDir(crewBaseDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
@@ -155,7 +210,7 @@ func (m *Manager) List() ([]*Worker, error) {
return nil, fmt.Errorf("reading crew dir: %w", err)
}
var workers []*Worker
var workers []*CrewWorker
for _, entry := range entries {
if !entry.IsDir() {
continue
@@ -172,48 +227,22 @@ func (m *Manager) List() ([]*Worker, error) {
}
// Get returns a specific crew worker by name.
func (m *Manager) Get(name string) (*Worker, error) {
func (m *Manager) Get(name string) (*CrewWorker, error) {
if !m.exists(name) {
return nil, ErrWorkerNotFound
return nil, ErrCrewNotFound
}
return m.loadState(name)
}
// SetState updates a crew worker's state.
func (m *Manager) SetState(name string, state State) error {
worker, err := m.Get(name)
if err != nil {
return err
}
worker.State = state
worker.UpdatedAt = time.Now()
return m.saveState(worker)
}
// SetBeadsDir updates the custom beads directory for a crew worker.
func (m *Manager) SetBeadsDir(name, beadsDir string) error {
worker, err := m.Get(name)
if err != nil {
return err
}
worker.BeadsDir = beadsDir
worker.UpdatedAt = time.Now()
return m.saveState(worker)
}
// saveState persists crew worker state to disk.
func (m *Manager) saveState(worker *Worker) error {
data, err := json.MarshalIndent(worker, "", " ")
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(worker.Name)
stateFile := m.stateFile(crew.Name)
if err := os.WriteFile(stateFile, data, 0644); err != nil {
return fmt.Errorf("writing state: %w", err)
}
@@ -222,49 +251,26 @@ func (m *Manager) saveState(worker *Worker) error {
}
// loadState reads crew worker state from disk.
func (m *Manager) loadState(name string) (*Worker, error) {
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 worker if state file missing
return &Worker{
// Return minimal crew worker if state file missing
return &CrewWorker{
Name: name,
Rig: m.rig.Name,
State: StateActive,
ClonePath: m.workerDir(name),
ClonePath: m.crewDir(name),
}, nil
}
return nil, fmt.Errorf("reading state: %w", err)
}
var worker Worker
if err := json.Unmarshal(data, &worker); err != nil {
var crew CrewWorker
if err := json.Unmarshal(data, &crew); err != nil {
return nil, fmt.Errorf("parsing state: %w", err)
}
return &worker, nil
}
// Names returns just the names of all crew workers.
func (m *Manager) Names() ([]string, error) {
crewDir := filepath.Join(m.rig.Path, "crew")
entries, err := os.ReadDir(crewDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading crew dir: %w", err)
}
var names []string
for _, entry := range entries {
if entry.IsDir() {
names = append(names, entry.Name())
}
}
return names, nil
return &crew, nil
}

View File

@@ -2,6 +2,7 @@ package crew
import (
"os"
"os/exec"
"path/filepath"
"testing"
@@ -9,194 +10,281 @@ import (
"github.com/steveyegge/gastown/internal/rig"
)
func TestManager_workerDir(t *testing.T) {
r := &rig.Rig{
Name: "test-rig",
Path: "/tmp/test-rig",
}
m := NewManager(r, nil)
got := m.workerDir("alice")
want := "/tmp/test-rig/crew/alice"
if got != want {
t.Errorf("workerDir() = %q, want %q", got, want)
}
}
func TestManager_stateFile(t *testing.T) {
r := &rig.Rig{
Name: "test-rig",
Path: "/tmp/test-rig",
}
m := NewManager(r, nil)
got := m.stateFile("bob")
want := "/tmp/test-rig/crew/bob/state.json"
if got != want {
t.Errorf("stateFile() = %q, want %q", got, want)
}
}
func TestManager_exists(t *testing.T) {
// Create temp directory structure
tmpDir := t.TempDir()
crewDir := filepath.Join(tmpDir, "crew", "existing-worker")
if err := os.MkdirAll(crewDir, 0755); err != nil {
t.Fatal(err)
}
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
m := NewManager(r, nil)
tests := []struct {
name string
worker string
want bool
}{
{"existing worker", "existing-worker", true},
{"non-existing worker", "non-existing", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.exists(tt.worker)
if got != tt.want {
t.Errorf("exists(%q) = %v, want %v", tt.worker, got, tt.want)
}
})
}
}
func TestManager_List_Empty(t *testing.T) {
tmpDir := t.TempDir()
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
m := NewManager(r, git.NewGit(tmpDir))
workers, err := m.List()
func TestManagerAddAndGet(t *testing.T) {
// Create temp directory for test
tmpDir, err := os.MkdirTemp("", "crew-test-*")
if err != nil {
t.Fatalf("List() error = %v", err)
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a mock rig
rigPath := filepath.Join(tmpDir, "test-rig")
if err := os.MkdirAll(rigPath, 0755); err != nil {
t.Fatalf("failed to create rig dir: %v", err)
}
// Initialize git repo for the rig
g := git.NewGit(rigPath)
// For testing, we need a git URL - use a local bare repo
bareRepoPath := filepath.Join(tmpDir, "bare-repo.git")
cmd := []string{"git", "init", "--bare", bareRepoPath}
if err := runCmd(cmd[0], cmd[1:]...); err != nil {
t.Fatalf("failed to create bare repo: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: rigPath,
GitURL: bareRepoPath,
}
mgr := NewManager(r, g)
// Test Add
worker, err := mgr.Add("dave", false)
if err != nil {
t.Fatalf("Add failed: %v", err)
}
if worker.Name != "dave" {
t.Errorf("expected name 'dave', got '%s'", worker.Name)
}
if worker.Rig != "test-rig" {
t.Errorf("expected rig 'test-rig', got '%s'", worker.Rig)
}
if worker.Branch != "main" {
t.Errorf("expected branch 'main', got '%s'", worker.Branch)
}
// Verify directory structure
crewDir := filepath.Join(rigPath, "crew", "dave")
if _, err := os.Stat(crewDir); os.IsNotExist(err) {
t.Error("crew directory was not created")
}
mailDir := filepath.Join(crewDir, "mail")
if _, err := os.Stat(mailDir); os.IsNotExist(err) {
t.Error("mail directory was not created")
}
claudeMD := filepath.Join(crewDir, "CLAUDE.md")
if _, err := os.Stat(claudeMD); os.IsNotExist(err) {
t.Error("CLAUDE.md was not created")
}
stateFile := filepath.Join(crewDir, "state.json")
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
t.Error("state.json was not created")
}
// Test Get
retrieved, err := mgr.Get("dave")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if retrieved.Name != "dave" {
t.Errorf("expected name 'dave', got '%s'", retrieved.Name)
}
// Test duplicate Add
_, err = mgr.Add("dave", false)
if err != ErrCrewExists {
t.Errorf("expected ErrCrewExists, got %v", err)
}
// Test Get non-existent
_, err = mgr.Get("nonexistent")
if err != ErrCrewNotFound {
t.Errorf("expected ErrCrewNotFound, got %v", err)
}
}
func TestManagerAddWithBranch(t *testing.T) {
// Create temp directory for test
tmpDir, err := os.MkdirTemp("", "crew-test-branch-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a mock rig
rigPath := filepath.Join(tmpDir, "test-rig")
if err := os.MkdirAll(rigPath, 0755); err != nil {
t.Fatalf("failed to create rig dir: %v", err)
}
g := git.NewGit(rigPath)
// Create a local repo with initial commit for branch testing
sourceRepoPath := filepath.Join(tmpDir, "source-repo")
if err := os.MkdirAll(sourceRepoPath, 0755); err != nil {
t.Fatalf("failed to create source repo dir: %v", err)
}
// Initialize source repo with a commit
cmds := [][]string{
{"git", "-C", sourceRepoPath, "init"},
{"git", "-C", sourceRepoPath, "config", "user.email", "test@test.com"},
{"git", "-C", sourceRepoPath, "config", "user.name", "Test"},
}
for _, cmd := range cmds {
if err := runCmd(cmd[0], cmd[1:]...); err != nil {
t.Fatalf("failed to run %v: %v", cmd, err)
}
}
// Create initial file and commit
testFile := filepath.Join(sourceRepoPath, "README.md")
if err := os.WriteFile(testFile, []byte("# Test"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cmds = [][]string{
{"git", "-C", sourceRepoPath, "add", "."},
{"git", "-C", sourceRepoPath, "commit", "-m", "Initial commit"},
}
for _, cmd := range cmds {
if err := runCmd(cmd[0], cmd[1:]...); err != nil {
t.Fatalf("failed to run %v: %v", cmd, err)
}
}
r := &rig.Rig{
Name: "test-rig",
Path: rigPath,
GitURL: sourceRepoPath,
}
mgr := NewManager(r, g)
// Test Add with branch
worker, err := mgr.Add("emma", true)
if err != nil {
t.Fatalf("Add with branch failed: %v", err)
}
if worker.Branch != "crew/emma" {
t.Errorf("expected branch 'crew/emma', got '%s'", worker.Branch)
}
}
func TestManagerList(t *testing.T) {
// Create temp directory for test
tmpDir, err := os.MkdirTemp("", "crew-test-list-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a mock rig
rigPath := filepath.Join(tmpDir, "test-rig")
if err := os.MkdirAll(rigPath, 0755); err != nil {
t.Fatalf("failed to create rig dir: %v", err)
}
g := git.NewGit(rigPath)
// Create a bare repo for cloning
bareRepoPath := filepath.Join(tmpDir, "bare-repo.git")
if err := runCmd("git", "init", "--bare", bareRepoPath); err != nil {
t.Fatalf("failed to create bare repo: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: rigPath,
GitURL: bareRepoPath,
}
mgr := NewManager(r, g)
// Initially empty
workers, err := mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(workers) != 0 {
t.Errorf("List() returned %d workers, want 0", len(workers))
t.Errorf("expected 0 workers, got %d", len(workers))
}
// Add some workers
_, err = mgr.Add("alice", false)
if err != nil {
t.Fatalf("Add alice failed: %v", err)
}
_, err = mgr.Add("bob", false)
if err != nil {
t.Fatalf("Add bob failed: %v", err)
}
workers, err = mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(workers) != 2 {
t.Errorf("expected 2 workers, got %d", len(workers))
}
}
func TestManager_List_WithWorkers(t *testing.T) {
tmpDir := t.TempDir()
func TestManagerRemove(t *testing.T) {
// Create temp directory for test
tmpDir, err := os.MkdirTemp("", "crew-test-remove-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create some fake worker directories
workers := []string{"alice", "bob", "charlie"}
for _, name := range workers {
workerDir := filepath.Join(tmpDir, "crew", name)
if err := os.MkdirAll(workerDir, 0755); err != nil {
t.Fatal(err)
}
// Create a mock rig
rigPath := filepath.Join(tmpDir, "test-rig")
if err := os.MkdirAll(rigPath, 0755); err != nil {
t.Fatalf("failed to create rig dir: %v", err)
}
g := git.NewGit(rigPath)
// Create a bare repo for cloning
bareRepoPath := filepath.Join(tmpDir, "bare-repo.git")
if err := runCmd("git", "init", "--bare", bareRepoPath); err != nil {
t.Fatalf("failed to create bare repo: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
Name: "test-rig",
Path: rigPath,
GitURL: bareRepoPath,
}
m := NewManager(r, git.NewGit(tmpDir))
gotWorkers, err := m.List()
mgr := NewManager(r, g)
// Add a worker
_, err = mgr.Add("charlie", false)
if err != nil {
t.Fatalf("List() error = %v", err)
t.Fatalf("Add failed: %v", err)
}
if len(gotWorkers) != len(workers) {
t.Errorf("List() returned %d workers, want %d", len(gotWorkers), len(workers))
}
}
func TestManager_Names(t *testing.T) {
tmpDir := t.TempDir()
// Create some fake worker directories
expected := []string{"alice", "bob"}
for _, name := range expected {
workerDir := filepath.Join(tmpDir, "crew", name)
if err := os.MkdirAll(workerDir, 0755); err != nil {
t.Fatal(err)
}
}
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
m := NewManager(r, git.NewGit(tmpDir))
names, err := m.Names()
// Remove it (with force since CLAUDE.md is uncommitted)
err = mgr.Remove("charlie", true)
if err != nil {
t.Fatalf("Names() error = %v", err)
t.Fatalf("Remove failed: %v", err)
}
if len(names) != len(expected) {
t.Errorf("Names() returned %d names, want %d", len(names), len(expected))
// Verify it's gone
_, err = mgr.Get("charlie")
if err != ErrCrewNotFound {
t.Errorf("expected ErrCrewNotFound, got %v", err)
}
// Remove non-existent
err = mgr.Remove("nonexistent", false)
if err != ErrCrewNotFound {
t.Errorf("expected ErrCrewNotFound, got %v", err)
}
}
func TestWorker_EffectiveBeadsDir(t *testing.T) {
tests := []struct {
name string
beadsDir string
rigPath string
want string
}{
{
name: "custom beads dir",
beadsDir: "/custom/beads",
rigPath: "/tmp/rig",
want: "/custom/beads",
},
{
name: "default beads dir",
beadsDir: "",
rigPath: "/tmp/rig",
want: "/tmp/rig/.beads",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &Worker{
BeadsDir: tt.beadsDir,
}
got := w.EffectiveBeadsDir(tt.rigPath)
if got != tt.want {
t.Errorf("EffectiveBeadsDir() = %q, want %q", got, tt.want)
}
})
}
}
func TestWorker_Summary(t *testing.T) {
w := &Worker{
Name: "alice",
State: StateActive,
Branch: "feature/test",
}
got := w.Summary()
if got.Name != w.Name {
t.Errorf("Summary().Name = %q, want %q", got.Name, w.Name)
}
if got.State != w.State {
t.Errorf("Summary().State = %q, want %q", got.State, w.State)
}
if got.Branch != w.Branch {
t.Errorf("Summary().Branch = %q, want %q", got.Branch, w.Branch)
}
// Helper to run commands
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
return cmd.Run()
}

View File

@@ -1,72 +1,39 @@
// Package crew provides crew worker management.
// Crew workers are user-managed persistent workspaces within a rig,
// as opposed to polecats which are AI-managed workers.
// Package crew provides crew workspace management for overseer workspaces.
package crew
import "time"
// State represents the current state of a crew worker.
type State string
const (
// StateActive means the crew workspace is active.
StateActive State = "active"
// StateInactive means the crew workspace is inactive.
StateInactive State = "inactive"
)
// Worker represents a crew member's workspace in a rig.
// Unlike polecats, crew workers are managed by the Overseer (human),
// not by AI agents.
type Worker struct {
// CrewWorker represents a user-managed workspace in a rig.
type CrewWorker struct {
// Name is the crew worker identifier.
Name string `json:"name"`
// Rig is the rig this worker belongs to.
// Rig is the rig this crew worker belongs to.
Rig string `json:"rig"`
// State is the current state.
State State `json:"state"`
// ClonePath is the path to the worker's clone.
// ClonePath is the path to the crew worker's clone of the rig.
ClonePath string `json:"clone_path"`
// Branch is the current git branch (if any).
Branch string `json:"branch,omitempty"`
// Branch is the current git branch.
Branch string `json:"branch"`
// BeadsDir is an optional custom beads directory.
// If empty, defaults to the rig's .beads/ directory.
BeadsDir string `json:"beads_dir,omitempty"`
// CreatedAt is when the worker was created.
// CreatedAt is when the crew worker was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt is when the worker was last updated.
// UpdatedAt is when the crew worker was last updated.
UpdatedAt time.Time `json:"updated_at"`
}
// Summary provides a concise view of crew worker status.
type Summary struct {
Name string `json:"name"`
State State `json:"state"`
Branch string `json:"branch,omitempty"`
Branch string `json:"branch"`
}
// Summary returns a Summary for this worker.
func (w *Worker) Summary() Summary {
// Summary returns a Summary for this crew worker.
func (c *CrewWorker) Summary() Summary {
return Summary{
Name: w.Name,
State: w.State,
Branch: w.Branch,
Name: c.Name,
Branch: c.Branch,
}
}
// EffectiveBeadsDir returns the beads directory to use.
// Returns the custom BeadsDir if set, otherwise returns the rig default path.
func (w *Worker) EffectiveBeadsDir(rigPath string) string {
if w.BeadsDir != "" {
return w.BeadsDir
}
return rigPath + "/.beads"
}