Files
gastown/internal/cmd/role.go
T
Steve Yegge fe19c8d15e feat: Wire up created_by field for beads issues (gt-u6nri)
- Add CreatedBy field to Issue struct (matches beads GH#748)
- Add Actor field to CreateOptions, pass --actor to bd create
- Add ActorString() method to RoleInfo for identity formatting
- Update all beads.Create() callers to pass Actor
- Update direct bd create exec calls with --actor:
  - mail/router.go: uses sender identity
  - patrol_helpers.go: uses role name
  - doctor/patrol_check.go: uses "gt-doctor"
  - rig/manager.go: uses "gt-rig-init"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:42:41 -08:00

474 lines
12 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// This is the canonical struct for role detection - used by both GetRole()
// and detectRole() functions.
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"`
WorkDir string `json:"work_dir,omitempty"` // Current working directory
}
var roleCmd = &cobra.Command{
Use: "role",
GroupID: GroupAgents,
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,
WorkDir: cwd,
}
// 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"
// For simple role strings like "crew" or "polecat", also check
// GT_RIG and GT_CREW/GT_POLECAT env vars for the full identity
if info.Rig == "" {
if envRig := os.Getenv("GT_RIG"); envRig != "" {
info.Rig = envRig
}
}
if info.Polecat == "" {
if envCrew := os.Getenv("GT_CREW"); envCrew != "" {
info.Polecat = envCrew
} else if envPolecat := os.Getenv("GT_POLECAT"); envPolecat != "" {
info.Polecat = envPolecat
}
}
// Check for mismatch with cwd detection
if cwdCtx.Role != RoleUnknown && cwdCtx.Role != parsedRole {
info.Mismatch = true
}
} else {
// Fall back to cwd detection - copy all fields from cwdCtx
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]
}
}
// ActorString returns the actor identity string for beads attribution.
// Format matches beads created_by convention:
// - Simple roles: "mayor", "deacon"
// - Rig-specific: "gastown/witness", "gastown/refinery"
// - Workers: "gastown/crew/max", "gastown/polecats/Toast"
func (info RoleInfo) ActorString() string {
switch info.Role {
case RoleMayor:
return "mayor"
case RoleDeacon:
return "deacon"
case RoleWitness:
if info.Rig != "" {
return fmt.Sprintf("%s/witness", info.Rig)
}
return "witness"
case RoleRefinery:
if info.Rig != "" {
return fmt.Sprintf("%s/refinery", info.Rig)
}
return "refinery"
case RolePolecat:
if info.Rig != "" && info.Polecat != "" {
return fmt.Sprintf("%s/polecats/%s", info.Rig, info.Polecat)
}
return "polecat"
case RoleCrew:
if info.Rig != "" && info.Polecat != "" {
return fmt.Sprintf("%s/crew/%s", info.Rig, info.Polecat)
}
return "crew"
default:
return string(info.Role)
}
}
// 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
}