feat: crew attach auto-detection, worktree polecats, beads mail
- gt crew at: auto-detect crew from cwd, run gt prime after launch - Polecats now use git worktrees from refinery (faster than clones) - Updated architecture.md for two-tier beads mail model - Town beads (gm-*) for Mayor mail/coordination - Rig .beads/ symlinks to refinery/rig/.beads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -83,7 +84,7 @@ Examples:
|
||||
}
|
||||
|
||||
var crewAtCmd = &cobra.Command{
|
||||
Use: "at <name>",
|
||||
Use: "at [name]",
|
||||
Aliases: []string{"attach"},
|
||||
Short: "Attach to crew workspace session",
|
||||
Long: `Start or attach to a tmux session for a crew workspace.
|
||||
@@ -91,10 +92,16 @@ var crewAtCmd = &cobra.Command{
|
||||
Creates a new tmux session if none exists, or attaches to existing.
|
||||
Use --no-tmux to just print the directory path instead.
|
||||
|
||||
Role Discovery:
|
||||
If no name is provided, attempts to detect the crew workspace from the
|
||||
current directory. If you're in <rig>/crew/<name>/, it will attach to
|
||||
that workspace automatically.
|
||||
|
||||
Examples:
|
||||
gt crew at dave # Attach to dave's session
|
||||
gt crew at # Auto-detect from cwd
|
||||
gt crew at dave --no-tmux # Just print path`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runCrewAt,
|
||||
}
|
||||
|
||||
@@ -184,7 +191,7 @@ func runCrewAdd(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
@@ -274,7 +281,7 @@ func getCrewManager(rigName string) (*crew.Manager, *rig.Rig, error) {
|
||||
}
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
@@ -386,7 +393,23 @@ func runCrewList(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
var name string
|
||||
|
||||
// Determine crew name: from arg, or auto-detect from cwd
|
||||
if len(args) > 0 {
|
||||
name = args[0]
|
||||
} else {
|
||||
// Try to detect from current directory
|
||||
detected, err := detectCrewFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at <name>", err)
|
||||
}
|
||||
name = detected.crewName
|
||||
if crewRig == "" {
|
||||
crewRig = detected.rigName
|
||||
}
|
||||
fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name)
|
||||
}
|
||||
|
||||
crewMgr, r, err := getCrewManager(crewRig)
|
||||
if err != nil {
|
||||
@@ -426,17 +449,89 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||
t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
||||
t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||
|
||||
// Start claude
|
||||
if err := t.SendKeys(sessionID, "claude"); err != nil {
|
||||
// Start claude with skip permissions (crew workers are trusted like Mayor)
|
||||
if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil {
|
||||
return fmt.Errorf("starting claude: %w", err)
|
||||
}
|
||||
|
||||
// Wait a moment for Claude to initialize, then prime it
|
||||
// We send gt prime after a short delay to ensure Claude is ready
|
||||
if err := t.SendKeysDelayed(sessionID, "gt prime", 2000); err != nil {
|
||||
// Non-fatal: Claude started but priming failed
|
||||
fmt.Printf("Warning: Could not send prime command: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created session for %s/%s\n",
|
||||
style.Bold.Render("✓"), r.Name, name)
|
||||
}
|
||||
|
||||
// Attach to session
|
||||
return t.AttachSession(sessionID)
|
||||
// Attach to session using exec to properly forward TTY
|
||||
return attachToTmuxSession(sessionID)
|
||||
}
|
||||
|
||||
// attachToTmuxSession attaches to a tmux session with proper TTY forwarding.
|
||||
func attachToTmuxSession(sessionID string) error {
|
||||
tmuxPath, err := exec.LookPath("tmux")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmux not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(tmuxPath, "attach-session", "-t", sessionID)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// crewDetection holds the result of detecting crew workspace from cwd.
|
||||
type crewDetection struct {
|
||||
rigName string
|
||||
crewName string
|
||||
}
|
||||
|
||||
// detectCrewFromCwd attempts to detect the crew workspace from the current directory.
|
||||
// It looks for the pattern <town>/<rig>/crew/<name>/ in the current path.
|
||||
func detectCrewFromCwd() (*crewDetection, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting cwd: %w", err)
|
||||
}
|
||||
|
||||
// Find town root
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not in Gas Town workspace: %w", err)
|
||||
}
|
||||
if townRoot == "" {
|
||||
return nil, fmt.Errorf("not in Gas Town workspace")
|
||||
}
|
||||
|
||||
// Get relative path from town root
|
||||
relPath, err := filepath.Rel(townRoot, cwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting relative path: %w", err)
|
||||
}
|
||||
|
||||
// Normalize and split path
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
parts := strings.Split(relPath, "/")
|
||||
|
||||
// Look for pattern: <rig>/crew/<name>/...
|
||||
// Minimum: rig, crew, name = 3 parts
|
||||
if len(parts) < 3 {
|
||||
return nil, fmt.Errorf("not in a crew workspace (path too short)")
|
||||
}
|
||||
|
||||
rigName := parts[0]
|
||||
if parts[1] != "crew" {
|
||||
return nil, fmt.Errorf("not in a crew workspace (not in crew/ directory)")
|
||||
}
|
||||
crewName := parts[2]
|
||||
|
||||
return &crewDetection{
|
||||
rigName: rigName,
|
||||
crewName: crewName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -26,9 +26,9 @@ var installCmd = &cobra.Command{
|
||||
Long: `Create a new Gas Town harness at the specified path.
|
||||
|
||||
A harness is the top-level directory where Gas Town is installed. It contains:
|
||||
- config/town.json Town configuration
|
||||
- config/rigs.json Registry of managed rigs
|
||||
- mayor/ Mayor agent home
|
||||
- CLAUDE.md Mayor role context (Mayor runs from harness root)
|
||||
- mayor/ Mayor config, state, and mail
|
||||
- rigs/ Managed rig clones (created by 'gt rig add')
|
||||
- .beads/redirect (optional) Default beads location
|
||||
|
||||
If path is omitted, uses the current directory.
|
||||
@@ -94,43 +94,43 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
// Create config directory
|
||||
configDir := filepath.Join(absPath, "config")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating config directory: %w", err)
|
||||
// Create mayor directory (holds config, state, and mail)
|
||||
mayorDir := filepath.Join(absPath, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating mayor directory: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Created config/\n")
|
||||
fmt.Printf(" ✓ Created mayor/\n")
|
||||
|
||||
// Create town.json
|
||||
// Create town.json in mayor/
|
||||
townConfig := &config.TownConfig{
|
||||
Type: "town",
|
||||
Version: config.CurrentTownVersion,
|
||||
Name: townName,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
townPath := filepath.Join(configDir, "town.json")
|
||||
townPath := filepath.Join(mayorDir, "town.json")
|
||||
if err := config.SaveTownConfig(townPath, townConfig); err != nil {
|
||||
return fmt.Errorf("writing town.json: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Created config/town.json\n")
|
||||
fmt.Printf(" ✓ Created mayor/town.json\n")
|
||||
|
||||
// Create rigs.json
|
||||
// Create rigs.json in mayor/
|
||||
rigsConfig := &config.RigsConfig{
|
||||
Version: config.CurrentRigsVersion,
|
||||
Rigs: make(map[string]config.RigEntry),
|
||||
}
|
||||
rigsPath := filepath.Join(configDir, "rigs.json")
|
||||
rigsPath := filepath.Join(mayorDir, "rigs.json")
|
||||
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
|
||||
return fmt.Errorf("writing rigs.json: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Created config/rigs.json\n")
|
||||
fmt.Printf(" ✓ Created mayor/rigs.json\n")
|
||||
|
||||
// Create mayor directory
|
||||
mayorDir := filepath.Join(absPath, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating mayor directory: %w", err)
|
||||
// Create rigs directory (for managed rig clones)
|
||||
rigsDir := filepath.Join(absPath, "rigs")
|
||||
if err := os.MkdirAll(rigsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating rigs directory: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Created mayor/\n")
|
||||
fmt.Printf(" ✓ Created rigs/\n")
|
||||
|
||||
// Create mayor mail directory
|
||||
mailDir := filepath.Join(mayorDir, "mail")
|
||||
@@ -156,22 +156,11 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Printf(" ✓ Created mayor/state.json\n")
|
||||
|
||||
// Create mayor config.json (this is what distinguishes town-level mayor)
|
||||
mayorConfig := map[string]interface{}{
|
||||
"type": "mayor",
|
||||
"version": 1,
|
||||
}
|
||||
mayorConfigPath := filepath.Join(mayorDir, "config.json")
|
||||
if err := writeJSON(mayorConfigPath, mayorConfig); err != nil {
|
||||
return fmt.Errorf("writing mayor config: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Created mayor/config.json\n")
|
||||
|
||||
// Create Mayor CLAUDE.md from template
|
||||
if err := createMayorCLAUDEmd(mayorDir, absPath); err != nil {
|
||||
// Create Mayor CLAUDE.md at harness root (Mayor runs from there)
|
||||
if err := createMayorCLAUDEmd(absPath, absPath); err != nil {
|
||||
fmt.Printf(" %s Could not create CLAUDE.md: %v\n", style.Dim.Render("⚠"), err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created mayor/CLAUDE.md\n")
|
||||
fmt.Printf(" ✓ Created CLAUDE.md\n")
|
||||
}
|
||||
|
||||
// Create .beads directory with redirect (optional)
|
||||
@@ -201,7 +190,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createMayorCLAUDEmd(mayorDir, townRoot string) error {
|
||||
func createMayorCLAUDEmd(harnessRoot, townRoot string) error {
|
||||
tmpl, err := templates.New()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -210,7 +199,7 @@ func createMayorCLAUDEmd(mayorDir, townRoot string) error {
|
||||
data := templates.RoleData{
|
||||
Role: "mayor",
|
||||
TownRoot: townRoot,
|
||||
WorkDir: mayorDir,
|
||||
WorkDir: harnessRoot,
|
||||
}
|
||||
|
||||
content, err := tmpl.RenderRole("mayor", data)
|
||||
@@ -218,7 +207,7 @@ func createMayorCLAUDEmd(mayorDir, townRoot string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
claudePath := filepath.Join(mayorDir, "CLAUDE.md")
|
||||
claudePath := filepath.Join(harnessRoot, "CLAUDE.md")
|
||||
return os.WriteFile(claudePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) {
|
||||
}
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
@@ -303,8 +303,8 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
|
||||
|
||||
fmt.Printf("Removing polecat %s/%s...\n", rigName, polecatName)
|
||||
|
||||
if err := mgr.Remove(polecatName); err != nil {
|
||||
if errors.Is(err, polecat.ErrHasChanges) && !polecatForce {
|
||||
if err := mgr.Remove(polecatName, polecatForce); err != nil {
|
||||
if errors.Is(err, polecat.ErrHasChanges) {
|
||||
return fmt.Errorf("polecat has uncommitted changes. Use --force to remove anyway")
|
||||
}
|
||||
return fmt.Errorf("removing polecat: %w", err)
|
||||
|
||||
@@ -102,7 +102,7 @@ func getRefineryManager(rigName string) (*refinery.Manager, *rig.Rig, error) {
|
||||
return nil, nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
|
||||
@@ -154,7 +154,7 @@ func getSessionManager(rigName string) (*session.Manager, *rig.Rig, error) {
|
||||
}
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
@@ -269,7 +269,7 @@ func runSessionList(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
|
||||
@@ -81,7 +81,7 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
|
||||
@@ -63,7 +63,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Load town config
|
||||
townConfigPath := filepath.Join(townRoot, "config", "town.json")
|
||||
townConfigPath := filepath.Join(townRoot, "mayor", "town.json")
|
||||
townConfig, err := config.LoadTownConfig(townConfigPath)
|
||||
if err != nil {
|
||||
// Try to continue without config
|
||||
@@ -71,7 +71,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
// Empty config if file doesn't exist
|
||||
|
||||
@@ -63,7 +63,7 @@ func runStop(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Load rigs config
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
|
||||
@@ -189,7 +189,7 @@ func getSwarmRig(rigName string) (*rig.Rig, string, error) {
|
||||
return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
@@ -212,7 +212,7 @@ func getAllRigs() ([]*rig.Rig, string, error) {
|
||||
return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json")
|
||||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
|
||||
|
||||
Reference in New Issue
Block a user