feat: add gt crew add command for user-managed workspaces

Implements gt-cik.2: Create crew workspace command with:
- internal/crew/types.go: CrewWorker type definition
- internal/crew/manager.go: Manager for crew lifecycle
- internal/crew/manager_test.go: Unit tests
- internal/cmd/crew.go: CLI command with --rig and --branch flags

Crew workers are user-managed persistent workspaces that:
- Clone repo into <rig>/crew/<name>/
- Create optional feature branch (crew/<name>)
- Set up mail directory for delivery
- Initialize CLAUDE.md with crew worker prompting
- Are NOT registered with witness (user-managed)

🤖 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:46:52 -08:00
parent 0c9c3f5563
commit 887c0f19d1
4 changed files with 761 additions and 0 deletions

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

@@ -0,0 +1,276 @@
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 (
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)
}
// 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.
func (m *Manager) createClaudeMD(name, crewPath string) error {
content := fmt.Sprintf(`# Claude: Crew Worker - %s
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
- **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, 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)
}
return &crew, nil
}

View File

@@ -0,0 +1,290 @@
package crew
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
func TestManagerAddAndGet(t *testing.T) {
// Create temp directory for test
tmpDir, err := os.MkdirTemp("", "crew-test-*")
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)
}
// 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("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 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 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)
// Add a worker
_, err = mgr.Add("charlie", false)
if err != nil {
t.Fatalf("Add failed: %v", err)
}
// Remove it (with force since CLAUDE.md is uncommitted)
err = mgr.Remove("charlie", true)
if err != nil {
t.Fatalf("Remove failed: %v", err)
}
// 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)
}
}
// Helper to run commands
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
return cmd.Run()
}

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

@@ -0,0 +1,39 @@
// Package crew provides crew workspace management for overseer workspaces.
package crew
import "time"
// 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 crew worker belongs to.
Rig string `json:"rig"`
// ClonePath is the path to the crew worker's clone of the rig.
ClonePath string `json:"clone_path"`
// Branch is the current git branch.
Branch string `json:"branch"`
// CreatedAt is when the crew worker was created.
CreatedAt time.Time `json:"created_at"`
// 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"`
Branch string `json:"branch"`
}
// Summary returns a Summary for this crew worker.
func (c *CrewWorker) Summary() Summary {
return Summary{
Name: c.Name,
Branch: c.Branch,
}
}