- New gt role subcommands: show, home, detect, list, env - Role detection now checks GT_ROLE env var first, falls back to cwd - gt prime shows warning when role/cwd mismatch detected - gt mol status uses env-aware role detection - gt handoff injects GT_ROLE and resets to role canonical home - Fixed witness/refinery home paths (was missing /rig suffix) This prevents role confusion when agents wander to wrong directories. After handoff, agents are always restored to their canonical home.
418 lines
10 KiB
Go
418 lines
10 KiB
Go
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
|
|
}
|