Files
gastown/internal/cmd/role.go
Julian Knutsen e7ca4908dc refactor(config): remove BEADS_DIR from agent environment and add doctor check (#455)
* fix(sling_test): update test for cook dir change

The cook command no longer needs database context and runs from cwd,
not the target rig directory. Update test to match this behavior
change from bd2a5ab5.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): skip tests requiring missing binaries, handle --allow-stale

- Add skipIfAgentBinaryMissing helper to skip tests when codex/gemini
  binaries aren't available in the test environment
- Update rig manager test stub to handle --allow-stale flag

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(config): remove BEADS_DIR from agent environment

Stop exporting BEADS_DIR in AgentEnv - agents should use beads redirect
mechanism instead of relying on environment variable. This prevents
prefix mismatches when agents operate across different beads databases.

Changes:
- Remove BeadsDir field from AgentEnvConfig
- Remove BEADS_DIR from env vars set on agent sessions
- Update doctor env_check to not expect BEADS_DIR
- Update all manager Start() calls to not pass BeadsDir

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(doctor): detect BEADS_DIR in tmux session environment

Add a doctor check that warns when BEADS_DIR is set in any Gas Town
tmux session. BEADS_DIR in the environment overrides prefix-based
routing and breaks multi-rig lookups - agents should use the beads
redirect mechanism instead.

The check:
- Iterates over all Gas Town tmux sessions (gt-* and hq-*)
- Checks if BEADS_DIR is set in the session environment
- Returns a warning with fix hint to restart sessions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:13:57 -08:00

529 lines
14 KiB
Go

package cmd
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"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
EnvIncomplete bool `json:"env_incomplete,omitempty"` // True if env was set but missing rig/polecat, filled from cwd
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 for the current role.
Role is determined from GT_ROLE environment variable or current working directory.
This is a read-only command that displays the current role's env vars.
Examples:
eval $(gt role env) # Export current role's env vars
gt role env # View what would be exported`,
RunE: runRoleEnv,
}
// Flags for role home command
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 and --polecat flags to home command for overrides
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
}
}
// If env is incomplete (missing rig/polecat for roles that need them),
// fill gaps from cwd detection and mark as incomplete
needsRig := parsedRole == RoleWitness || parsedRole == RoleRefinery || parsedRole == RolePolecat || parsedRole == RoleCrew
needsPolecat := parsedRole == RolePolecat || parsedRole == RoleCrew
if needsRig && info.Rig == "" && cwdCtx.Rig != "" {
info.Rig = cwdCtx.Rig
info.EnvIncomplete = true
}
if needsPolecat && info.Polecat == "" && cwdCtx.Polecat != "" {
info.Polecat = cwdCtx.Polecat
info.EnvIncomplete = true
}
// 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 filepath.Join(townRoot, "mayor")
case RoleDeacon:
return filepath.Join(townRoot, "deacon")
case RoleWitness:
if rig == "" {
return ""
}
return filepath.Join(townRoot, rig, "witness")
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, "rig")
case RoleCrew:
if rig == "" || polecat == "" {
return ""
}
return filepath.Join(townRoot, rig, "crew", polecat, "rig")
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 {
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")
}
// Validate flag combinations: --polecat requires --rig to prevent strange merges
if rolePolecat != "" && roleRig == "" {
return fmt.Errorf("--polecat requires --rig to be specified")
}
// Start with current role detection (from env vars or cwd)
info, err := GetRole()
if err != nil {
return err
}
role := info.Role
rig := info.Rig
polecat := info.Polecat
// Apply overrides from arguments/flags
if len(args) > 0 {
role, _, _ = parseRoleString(args[0])
}
if roleRig != "" {
rig = roleRig
}
if rolePolecat != "" {
polecat = rolePolecat
}
home := getRoleHome(role, rig, polecat, townRoot)
if home == "" {
return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", role, rig, polecat)
}
// Warn if computed home doesn't match cwd
if home != cwd && !strings.HasPrefix(cwd, home) {
fmt.Fprintf(os.Stderr, "⚠️ Warning: cwd (%s) is not within role home (%s)\n", cwd, home)
}
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 mayor/"},
{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 {
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")
}
// Get current role (read-only - from env vars or cwd)
info, err := GetRole()
if err != nil {
return err
}
home := getRoleHome(info.Role, info.Rig, info.Polecat, townRoot)
if home == "" {
return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", info.Role, info.Rig, info.Polecat)
}
// Warn if env was incomplete and we filled from cwd
if info.EnvIncomplete {
fmt.Fprintf(os.Stderr, "⚠️ Warning: env vars incomplete, filled from cwd\n")
}
// Warn if computed home doesn't match cwd
if home != cwd && !strings.HasPrefix(cwd, home) {
fmt.Fprintf(os.Stderr, "⚠️ Warning: cwd (%s) is not within role home (%s)\n", cwd, home)
}
// Get canonical env vars from shared source of truth
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: string(info.Role),
Rig: info.Rig,
AgentName: info.Polecat,
TownRoot: townRoot,
})
envVars[EnvGTRoleHome] = home
// Output in sorted order for consistent output
keys := make([]string, 0, len(envVars))
for k := range envVars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("export %s=%s\n", k, envVars[k])
}
return nil
}