diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index 2af52729..8808e5e9 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -260,13 +260,23 @@ func buildRestartCommand(sessionName string) (string, error) { return "", err } - // For respawn-pane, we cd to the right directory then run claude. + // Determine GT_ROLE value for this session + gtRole := sessionToGTRole(sessionName) + + // For respawn-pane, we: + // 1. cd to the right directory (role's canonical home) + // 2. export GT_ROLE so role detection works correctly + // 3. run claude // The SessionStart hook will run gt prime. // Use exec to ensure clean process replacement. + if gtRole != "" { + return fmt.Sprintf("cd %s && export GT_ROLE=%s && exec claude --dangerously-skip-permissions", workDir, gtRole), nil + } return fmt.Sprintf("cd %s && exec claude --dangerously-skip-permissions", workDir), nil } // sessionWorkDir returns the correct working directory for a session. +// This is the canonical home for each role type. func sessionWorkDir(sessionName, townRoot string) (string, error) { switch { case sessionName == "gt-mayor": @@ -292,22 +302,53 @@ func sessionWorkDir(sessionName, townRoot string) (string, error) { return "", fmt.Errorf("cannot parse crew session name: %s", sessionName) case strings.HasSuffix(sessionName, "-witness"): - // gt--witness -> //witness + // gt--witness -> //witness/rig rig := strings.TrimPrefix(sessionName, "gt-") rig = strings.TrimSuffix(rig, "-witness") - return fmt.Sprintf("%s/%s/witness", townRoot, rig), nil + return fmt.Sprintf("%s/%s/witness/rig", townRoot, rig), nil case strings.HasSuffix(sessionName, "-refinery"): - // gt--refinery -> //refinery + // gt--refinery -> //refinery/rig rig := strings.TrimPrefix(sessionName, "gt-") rig = strings.TrimSuffix(rig, "-refinery") - return fmt.Sprintf("%s/%s/refinery", townRoot, rig), nil + return fmt.Sprintf("%s/%s/refinery/rig", townRoot, rig), nil default: return "", fmt.Errorf("unknown session type: %s (try specifying role explicitly)", sessionName) } } +// sessionToGTRole converts a session name to a GT_ROLE value. +func sessionToGTRole(sessionName string) string { + switch { + case sessionName == "gt-mayor": + return "mayor" + case sessionName == "gt-deacon": + return "deacon" + case strings.Contains(sessionName, "-crew-"): + // gt--crew- -> /crew/ + parts := strings.Split(sessionName, "-") + for i, p := range parts { + if p == "crew" && i > 1 && i < len(parts)-1 { + rig := strings.Join(parts[1:i], "-") + name := strings.Join(parts[i+1:], "-") + return fmt.Sprintf("%s/crew/%s", rig, name) + } + } + return "" + case strings.HasSuffix(sessionName, "-witness"): + rig := strings.TrimPrefix(sessionName, "gt-") + rig = strings.TrimSuffix(rig, "-witness") + return fmt.Sprintf("%s/witness", rig) + case strings.HasSuffix(sessionName, "-refinery"): + rig := strings.TrimPrefix(sessionName, "gt-") + rig = strings.TrimSuffix(rig, "-refinery") + return fmt.Sprintf("%s/refinery", rig) + default: + return "" + } +} + // detectTownRootFromCwd walks up from the current directory to find the town root. func detectTownRootFromCwd() string { cwd, err := os.Getwd() diff --git a/internal/cmd/molecule_status.go b/internal/cmd/molecule_status.go index 9d2b3ef9..7402c91f 100644 --- a/internal/cmd/molecule_status.go +++ b/internal/cmd/molecule_status.go @@ -215,11 +215,21 @@ func runMoleculeStatus(cmd *cobra.Command, args []string) error { // Explicit target provided target = args[0] } else { - // Auto-detect from current directory - roleCtx = detectRole(cwd, townRoot) + // Auto-detect using env-aware role detection + roleInfo, err := GetRoleWithContext(cwd, townRoot) + if err != nil { + return fmt.Errorf("detecting role: %w", err) + } + roleCtx = RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } target = buildAgentIdentity(roleCtx) if target == "" { - return fmt.Errorf("cannot determine agent identity from current directory (role: %s)", roleCtx.Role) + return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role) } } @@ -546,11 +556,21 @@ func runMoleculeCurrent(cmd *cobra.Command, args []string) error { // Explicit target provided target = args[0] } else { - // Auto-detect from current directory - roleCtx = detectRole(cwd, townRoot) + // Auto-detect using env-aware role detection + roleInfo, err := GetRoleWithContext(cwd, townRoot) + if err != nil { + return fmt.Errorf("detecting role: %w", err) + } + roleCtx = RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } target = buildAgentIdentity(roleCtx) if target == "" { - return fmt.Errorf("cannot determine agent identity from current directory (role: %s)", roleCtx.Role) + return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role) } } diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index d28e53f1..10fe82d4 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -75,8 +75,35 @@ func runPrime(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a Gas Town workspace") } - // Detect role - ctx := detectRole(cwd, townRoot) + // Get role using env-aware detection + roleInfo, err := GetRoleWithContext(cwd, townRoot) + if err != nil { + return fmt.Errorf("detecting role: %w", err) + } + + // Warn prominently if there's a role/cwd mismatch + if roleInfo.Mismatch { + fmt.Printf("\n%s\n", style.Bold.Render("⚠️ ROLE/LOCATION MISMATCH")) + fmt.Printf("You are %s (from $GT_ROLE) but your cwd suggests %s.\n", + style.Bold.Render(string(roleInfo.Role)), + style.Bold.Render(string(roleInfo.CwdRole))) + fmt.Printf("Expected home: %s\n", roleInfo.Home) + fmt.Printf("Actual cwd: %s\n", cwd) + fmt.Println() + fmt.Println("This can cause commands to misbehave. Either:") + fmt.Println(" 1. cd to your home directory, OR") + fmt.Println(" 2. Use absolute paths for gt/bd commands") + fmt.Println() + } + + // Build RoleContext for compatibility with existing code + ctx := RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } // Check and acquire identity lock for worker roles if err := acquireIdentityLock(ctx); err != nil { diff --git a/internal/cmd/role.go b/internal/cmd/role.go new file mode 100644 index 00000000..172153e8 --- /dev/null +++ b/internal/cmd/role.go @@ -0,0 +1,417 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// Environment variables for role detection +const ( + EnvGTRole = "GT_ROLE" + EnvGTRoleHome = "GT_ROLE_HOME" +) + +// RoleInfo contains information about a role and its detection source. +type RoleInfo struct { + Role Role `json:"role"` + Source string `json:"source"` // "env", "cwd", or "explicit" + Home string `json:"home"` + Rig string `json:"rig,omitempty"` + Polecat string `json:"polecat,omitempty"` + EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set + CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd + Mismatch bool `json:"mismatch,omitempty"` // True if env != cwd detection + TownRoot string `json:"town_root,omitempty"` +} + +var roleCmd = &cobra.Command{ + Use: "role", + Short: "Show or manage agent role", + Long: `Display the current agent role and its detection source. + +Role is determined by: +1. GT_ROLE environment variable (authoritative if set) +2. Current working directory (fallback) + +If both are available and disagree, a warning is shown.`, + RunE: runRoleShow, +} + +var roleShowCmd = &cobra.Command{ + Use: "show", + Short: "Show current role", + RunE: runRoleShow, +} + +var roleHomeCmd = &cobra.Command{ + Use: "home [ROLE]", + Short: "Show home directory for a role", + Long: `Show the canonical home directory for a role. + +If no role is specified, shows the home for the current role. + +Examples: + gt role home # Home for current role + gt role home mayor # Home for mayor + gt role home witness # Home for witness (requires --rig)`, + Args: cobra.MaximumNArgs(1), + RunE: runRoleHome, +} + +var roleDetectCmd = &cobra.Command{ + Use: "detect", + Short: "Force cwd-based role detection (debugging)", + Long: `Detect role from current working directory, ignoring GT_ROLE env var. + +This is useful for debugging role detection issues.`, + RunE: runRoleDetect, +} + +var roleListCmd = &cobra.Command{ + Use: "list", + Short: "List all known roles", + RunE: runRoleList, +} + +var roleEnvCmd = &cobra.Command{ + Use: "env", + Short: "Print export statements for current role", + Long: `Print shell export statements to set GT_ROLE and GT_ROLE_HOME. + +Usage: + eval $(gt role env) # Set role env vars in current shell`, + RunE: runRoleEnv, +} + +// Flags +var ( + roleRig string + rolePolecat string +) + +func init() { + rootCmd.AddCommand(roleCmd) + roleCmd.AddCommand(roleShowCmd) + roleCmd.AddCommand(roleHomeCmd) + roleCmd.AddCommand(roleDetectCmd) + roleCmd.AddCommand(roleListCmd) + roleCmd.AddCommand(roleEnvCmd) + + // Add --rig flag to home command for witness/refinery/polecat + roleHomeCmd.Flags().StringVar(&roleRig, "rig", "", "Rig name (required for rig-specific roles)") + roleHomeCmd.Flags().StringVar(&rolePolecat, "polecat", "", "Polecat/crew member name") +} + +// GetRole returns the current role, checking GT_ROLE first then falling back to cwd. +// This is the canonical function for role detection. +func GetRole() (RoleInfo, error) { + cwd, err := os.Getwd() + if err != nil { + return RoleInfo{}, fmt.Errorf("getting current directory: %w", err) + } + + townRoot, err := workspace.FindFromCwd() + if err != nil { + return RoleInfo{}, fmt.Errorf("finding workspace: %w", err) + } + if townRoot == "" { + return RoleInfo{}, fmt.Errorf("not in a Gas Town workspace") + } + + return GetRoleWithContext(cwd, townRoot) +} + +// GetRoleWithContext returns role info given explicit cwd and town root. +func GetRoleWithContext(cwd, townRoot string) (RoleInfo, error) { + info := RoleInfo{ + TownRoot: townRoot, + } + + // Check environment variable first + envRole := os.Getenv(EnvGTRole) + info.EnvRole = envRole + + // Always detect from cwd for comparison/fallback + cwdCtx := detectRole(cwd, townRoot) + info.CwdRole = cwdCtx.Role + + // Determine authoritative role + if envRole != "" { + // Parse env role - it might be simple ("mayor") or compound ("gastown/witness") + parsedRole, rig, polecat := parseRoleString(envRole) + info.Role = parsedRole + info.Rig = rig + info.Polecat = polecat + info.Source = "env" + + // Check for mismatch with cwd detection + if cwdCtx.Role != RoleUnknown && cwdCtx.Role != parsedRole { + info.Mismatch = true + } + } else { + // Fall back to cwd detection + info.Role = cwdCtx.Role + info.Rig = cwdCtx.Rig + info.Polecat = cwdCtx.Polecat + info.Source = "cwd" + } + + // Determine home directory + info.Home = getRoleHome(info.Role, info.Rig, info.Polecat, townRoot) + + return info, nil +} + +// parseRoleString parses a role string like "mayor", "gastown/witness", or "gastown/polecats/alpha". +func parseRoleString(s string) (Role, string, string) { + s = strings.TrimSpace(s) + + // Simple roles + switch s { + case "mayor": + return RoleMayor, "", "" + case "deacon": + return RoleDeacon, "", "" + } + + // Compound roles: rig/role or rig/polecats/name or rig/crew/name + parts := strings.Split(s, "/") + if len(parts) < 2 { + // Unknown format, try to match as simple role + return Role(s), "", "" + } + + rig := parts[0] + + switch parts[1] { + case "witness": + return RoleWitness, rig, "" + case "refinery": + return RoleRefinery, rig, "" + case "polecats": + if len(parts) >= 3 { + return RolePolecat, rig, parts[2] + } + return RolePolecat, rig, "" + case "crew": + if len(parts) >= 3 { + return RoleCrew, rig, parts[2] + } + return RoleCrew, rig, "" + default: + // Might be rig/polecatName format + return RolePolecat, rig, parts[1] + } +} + +// getRoleHome returns the canonical home directory for a role. +func getRoleHome(role Role, rig, polecat, townRoot string) string { + switch role { + case RoleMayor: + return townRoot + case RoleDeacon: + return filepath.Join(townRoot, "deacon") + case RoleWitness: + if rig == "" { + return "" + } + return filepath.Join(townRoot, rig, "witness", "rig") + case RoleRefinery: + if rig == "" { + return "" + } + return filepath.Join(townRoot, rig, "refinery", "rig") + case RolePolecat: + if rig == "" || polecat == "" { + return "" + } + return filepath.Join(townRoot, rig, "polecats", polecat) + case RoleCrew: + if rig == "" || polecat == "" { + return "" + } + return filepath.Join(townRoot, rig, "crew", polecat) + default: + return "" + } +} + +func runRoleShow(cmd *cobra.Command, args []string) error { + info, err := GetRole() + if err != nil { + return err + } + + // Header + fmt.Printf("%s\n", style.Bold.Render(string(info.Role))) + fmt.Printf("Source: %s\n", info.Source) + + if info.Home != "" { + fmt.Printf("Home: %s\n", info.Home) + } + + if info.Rig != "" { + fmt.Printf("Rig: %s\n", info.Rig) + } + + if info.Polecat != "" { + fmt.Printf("Worker: %s\n", info.Polecat) + } + + // Show mismatch warning + if info.Mismatch { + fmt.Println() + fmt.Printf("%s\n", style.Bold.Render("⚠️ ROLE MISMATCH")) + fmt.Printf(" GT_ROLE=%s (authoritative)\n", info.EnvRole) + fmt.Printf(" cwd suggests: %s\n", info.CwdRole) + fmt.Println() + fmt.Println("The GT_ROLE env var takes precedence, but you may be in the wrong directory.") + fmt.Printf("Expected home: %s\n", info.Home) + } + + return nil +} + +func runRoleHome(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding workspace: %w", err) + } + if townRoot == "" { + return fmt.Errorf("not in a Gas Town workspace") + } + + var role Role + var rig, polecat string + + if len(args) > 0 { + // Explicit role provided + role, rig, polecat = parseRoleString(args[0]) + + // Override with flags if provided + if roleRig != "" { + rig = roleRig + } + if rolePolecat != "" { + polecat = rolePolecat + } + } else { + // Use current role + info, err := GetRole() + if err != nil { + return err + } + role = info.Role + rig = info.Rig + polecat = info.Polecat + } + + home := getRoleHome(role, rig, polecat, townRoot) + if home == "" { + return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", role, rig, polecat) + } + + fmt.Println(home) + return nil +} + +func runRoleDetect(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding workspace: %w", err) + } + if townRoot == "" { + return fmt.Errorf("not in a Gas Town workspace") + } + + ctx := detectRole(cwd, townRoot) + + fmt.Printf("%s (from cwd)\n", style.Bold.Render(string(ctx.Role))) + fmt.Printf("Directory: %s\n", cwd) + + if ctx.Rig != "" { + fmt.Printf("Rig: %s\n", ctx.Rig) + } + if ctx.Polecat != "" { + fmt.Printf("Worker: %s\n", ctx.Polecat) + } + + // Check if env var disagrees + envRole := os.Getenv(EnvGTRole) + if envRole != "" { + parsedRole, _, _ := parseRoleString(envRole) + if parsedRole != ctx.Role { + fmt.Println() + fmt.Printf("%s\n", style.Bold.Render("⚠️ Mismatch with $GT_ROLE")) + fmt.Printf(" $GT_ROLE=%s\n", envRole) + fmt.Println(" The env var takes precedence in normal operation.") + } + } + + return nil +} + +func runRoleList(cmd *cobra.Command, args []string) error { + roles := []struct { + name Role + desc string + }{ + {RoleMayor, "Global coordinator at town root"}, + {RoleDeacon, "Background supervisor daemon"}, + {RoleWitness, "Per-rig polecat lifecycle manager"}, + {RoleRefinery, "Per-rig merge queue processor"}, + {RolePolecat, "Ephemeral worker with own worktree"}, + {RoleCrew, "Persistent worker with own worktree"}, + } + + fmt.Println("Available roles:") + fmt.Println() + for _, r := range roles { + fmt.Printf(" %-10s %s\n", style.Bold.Render(string(r.name)), r.desc) + } + return nil +} + +func runRoleEnv(cmd *cobra.Command, args []string) error { + info, err := GetRole() + if err != nil { + return err + } + + // Build the role string for GT_ROLE + var roleStr string + switch info.Role { + case RoleMayor: + roleStr = "mayor" + case RoleDeacon: + roleStr = "deacon" + case RoleWitness: + roleStr = fmt.Sprintf("%s/witness", info.Rig) + case RoleRefinery: + roleStr = fmt.Sprintf("%s/refinery", info.Rig) + case RolePolecat: + roleStr = fmt.Sprintf("%s/polecats/%s", info.Rig, info.Polecat) + case RoleCrew: + roleStr = fmt.Sprintf("%s/crew/%s", info.Rig, info.Polecat) + default: + roleStr = string(info.Role) + } + + fmt.Printf("export %s=%s\n", EnvGTRole, roleStr) + if info.Home != "" { + fmt.Printf("export %s=%s\n", EnvGTRoleHome, info.Home) + } + + return nil +}