feat: add crew directory structure and manager

Add crew/ directory support to rig structure for user-managed
persistent workspaces. Crew workers are separate from polecats
(AI-managed) and can have optional custom BEADS_DIR configuration.

- Add internal/crew package with Worker type and Manager
- Update rig types to include Crew slice and CrewCount in summary
- Update rig manager to scan for crew workers
- Add crew/ to AgentDirs for rig initialization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-16 20:31:18 -08:00
parent 0c9c3f5563
commit 05f692cc2f
5 changed files with 567 additions and 6 deletions

270
internal/crew/manager.go Normal file
View File

@@ -0,0 +1,270 @@
package crew
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
// 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")
)
// 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,
}
}
// workerDir returns the directory for a crew worker.
func (m *Manager) workerDir(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")
}
// exists checks if a crew worker exists.
func (m *Manager) exists(name string) bool {
_, err := os.Stat(m.workerDir(name))
return err == nil
}
// Add creates a new crew worker with a clone of the rig.
func (m *Manager) Add(name string) (*Worker, error) {
if m.exists(name) {
return nil, ErrWorkerExists
}
workerPath := m.workerDir(name)
// Create crew directory if needed
crewDir := filepath.Join(m.rig.Path, "crew")
if err := os.MkdirAll(crewDir, 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 {
return nil, fmt.Errorf("cloning rig: %w", err)
}
// Create crew worker state
now := time.Now()
worker := &Worker{
Name: name,
Rig: m.rig.Name,
State: StateActive,
ClonePath: workerPath,
CreatedAt: now,
UpdatedAt: now,
}
// Save state
if err := m.saveState(worker); err != nil {
os.RemoveAll(workerPath)
return nil, fmt.Errorf("saving state: %w", err)
}
return worker, 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
}
// Update with custom config
if beadsDir != "" {
worker.BeadsDir = beadsDir
if err := m.saveState(worker); err != nil {
return nil, fmt.Errorf("saving config: %w", err)
}
}
return worker, nil
}
// Remove deletes a crew worker.
func (m *Manager) Remove(name string) error {
if !m.exists(name) {
return ErrWorkerNotFound
}
workerPath := m.workerDir(name)
workerGit := git.NewGit(workerPath)
// Check for uncommitted changes
hasChanges, err := workerGit.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)
}
return nil
}
// List returns all crew workers in the rig.
func (m *Manager) List() ([]*Worker, 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 workers []*Worker
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) (*Worker, error) {
if !m.exists(name) {
return nil, ErrWorkerNotFound
}
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, "", " ")
if err != nil {
return fmt.Errorf("marshaling state: %w", err)
}
stateFile := m.stateFile(worker.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) (*Worker, 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{
Name: name,
Rig: m.rig.Name,
State: StateActive,
ClonePath: m.workerDir(name),
}, nil
}
return nil, fmt.Errorf("reading state: %w", err)
}
var worker Worker
if err := json.Unmarshal(data, &worker); 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
}

View File

@@ -0,0 +1,202 @@
package crew
import (
"os"
"path/filepath"
"testing"
"github.com/steveyegge/gastown/internal/git"
"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()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(workers) != 0 {
t.Errorf("List() returned %d workers, want 0", len(workers))
}
}
func TestManager_List_WithWorkers(t *testing.T) {
tmpDir := t.TempDir()
// 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)
}
}
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
m := NewManager(r, git.NewGit(tmpDir))
gotWorkers, err := m.List()
if err != nil {
t.Fatalf("List() error = %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()
if err != nil {
t.Fatalf("Names() error = %v", err)
}
if len(names) != len(expected) {
t.Errorf("Names() returned %d names, want %d", len(names), len(expected))
}
}
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)
}
}

72
internal/crew/types.go Normal file
View File

@@ -0,0 +1,72 @@
// 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
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 {
// Name is the crew worker identifier.
Name string `json:"name"`
// Rig is the rig this 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 string `json:"clone_path"`
// Branch is the current git branch (if any).
Branch string `json:"branch,omitempty"`
// 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 time.Time `json:"created_at"`
// UpdatedAt is when the 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"`
}
// Summary returns a Summary for this worker.
func (w *Worker) Summary() Summary {
return Summary{
Name: w.Name,
State: w.State,
Branch: w.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"
}

View File

@@ -94,6 +94,16 @@ func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) {
}
}
// 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
witnessPath := filepath.Join(rigPath, "witness", "rig")
if _, err := os.Stat(witnessPath); err == nil {

View File

@@ -22,6 +22,10 @@ type Rig struct {
// Polecats is the list of polecat names in this rig.
Polecats []string `json:"polecats,omitempty"`
// Crew is the list of crew worker names in this rig.
// Crew workers are user-managed persistent workspaces.
Crew []string `json:"crew,omitempty"`
// HasWitness indicates if the rig has a witness agent.
HasWitness bool `json:"has_witness"`
@@ -35,6 +39,7 @@ type Rig struct {
// AgentDirs are the standard agent directories in a rig.
var AgentDirs = []string{
"polecats",
"crew",
"refinery/rig",
"witness/rig",
"mayor/rig",
@@ -42,18 +47,20 @@ var AgentDirs = []string{
// RigSummary provides a concise overview of a rig.
type RigSummary struct {
Name string `json:"name"`
Name string `json:"name"`
PolecatCount int `json:"polecat_count"`
HasWitness bool `json:"has_witness"`
HasRefinery bool `json:"has_refinery"`
CrewCount int `json:"crew_count"`
HasWitness bool `json:"has_witness"`
HasRefinery bool `json:"has_refinery"`
}
// Summary returns a RigSummary for this rig.
func (r *Rig) Summary() RigSummary {
return RigSummary{
Name: r.Name,
Name: r.Name,
PolecatCount: len(r.Polecats),
HasWitness: r.HasWitness,
HasRefinery: r.HasRefinery,
CrewCount: len(r.Crew),
HasWitness: r.HasWitness,
HasRefinery: r.HasRefinery,
}
}