Files
gastown/internal/config/loader.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

1642 lines
50 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/steveyegge/gastown/internal/constants"
)
var (
// ErrNotFound indicates the config file does not exist.
ErrNotFound = errors.New("config file not found")
// ErrInvalidVersion indicates an unsupported schema version.
ErrInvalidVersion = errors.New("unsupported config version")
// ErrInvalidType indicates an unexpected config type.
ErrInvalidType = errors.New("invalid config type")
// ErrMissingField indicates a required field is missing.
ErrMissingField = errors.New("missing required field")
)
// LoadTownConfig loads and validates a town configuration file.
func LoadTownConfig(path string) (*TownConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is from trusted config location
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading config: %w", err)
}
var config TownConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateTownConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveTownConfig saves a town configuration to a file.
func SaveTownConfig(path string, config *TownConfig) error {
if err := validateTownConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding config: %w", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}
// LoadRigsConfig loads and validates a rigs registry file.
func LoadRigsConfig(path string) (*RigsConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading config: %w", err)
}
var config RigsConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateRigsConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveRigsConfig saves a rigs registry to a file.
func SaveRigsConfig(path string, config *RigsConfig) error {
if err := validateRigsConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding config: %w", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}
// validateTownConfig validates a TownConfig.
func validateTownConfig(c *TownConfig) error {
if c.Type != "town" && c.Type != "" {
return fmt.Errorf("%w: expected type 'town', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentTownVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentTownVersion)
}
if c.Name == "" {
return fmt.Errorf("%w: name", ErrMissingField)
}
return nil
}
// validateRigsConfig validates a RigsConfig.
func validateRigsConfig(c *RigsConfig) error {
if c.Version > CurrentRigsVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigsVersion)
}
if c.Rigs == nil {
c.Rigs = make(map[string]RigEntry)
}
return nil
}
// LoadRigConfig loads and validates a rig configuration file.
func LoadRigConfig(path string) (*RigConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading config: %w", err)
}
var config RigConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateRigConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveRigConfig saves a rig configuration to a file.
func SaveRigConfig(path string, config *RigConfig) error {
if err := validateRigConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: config files don't contain secrets
return fmt.Errorf("writing config: %w", err)
}
return nil
}
// validateRigConfig validates a RigConfig (identity only).
func validateRigConfig(c *RigConfig) error {
if c.Type != "rig" && c.Type != "" {
return fmt.Errorf("%w: expected type 'rig', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentRigConfigVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigConfigVersion)
}
if c.Name == "" {
return fmt.Errorf("%w: name", ErrMissingField)
}
return nil
}
// validateRigSettings validates a RigSettings.
func validateRigSettings(c *RigSettings) error {
if c.Type != "rig-settings" && c.Type != "" {
return fmt.Errorf("%w: expected type 'rig-settings', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentRigSettingsVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigSettingsVersion)
}
if c.MergeQueue != nil {
if err := validateMergeQueueConfig(c.MergeQueue); err != nil {
return err
}
}
return nil
}
// ErrInvalidOnConflict indicates an invalid on_conflict strategy.
var ErrInvalidOnConflict = errors.New("invalid on_conflict strategy")
// validateMergeQueueConfig validates a MergeQueueConfig.
func validateMergeQueueConfig(c *MergeQueueConfig) error {
// Validate on_conflict strategy
if c.OnConflict != "" && c.OnConflict != OnConflictAssignBack && c.OnConflict != OnConflictAutoRebase {
return fmt.Errorf("%w: got '%s', want '%s' or '%s'",
ErrInvalidOnConflict, c.OnConflict, OnConflictAssignBack, OnConflictAutoRebase)
}
// Validate poll_interval if specified
if c.PollInterval != "" {
if _, err := time.ParseDuration(c.PollInterval); err != nil {
return fmt.Errorf("invalid poll_interval: %w", err)
}
}
// Validate non-negative values
if c.RetryFlakyTests < 0 {
return fmt.Errorf("%w: retry_flaky_tests must be non-negative", ErrMissingField)
}
if c.MaxConcurrent < 0 {
return fmt.Errorf("%w: max_concurrent must be non-negative", ErrMissingField)
}
return nil
}
// NewRigConfig creates a new RigConfig (identity only).
func NewRigConfig(name, gitURL string) *RigConfig {
return &RigConfig{
Type: "rig",
Version: CurrentRigConfigVersion,
Name: name,
GitURL: gitURL,
}
}
// NewRigSettings creates a new RigSettings with defaults.
func NewRigSettings() *RigSettings {
return &RigSettings{
Type: "rig-settings",
Version: CurrentRigSettingsVersion,
MergeQueue: DefaultMergeQueueConfig(),
Namepool: DefaultNamepoolConfig(),
}
}
// LoadRigSettings loads and validates a rig settings file.
func LoadRigSettings(path string) (*RigSettings, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading settings: %w", err)
}
var settings RigSettings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, fmt.Errorf("parsing settings: %w", err)
}
if err := validateRigSettings(&settings); err != nil {
return nil, err
}
return &settings, nil
}
// SaveRigSettings saves rig settings to a file.
func SaveRigSettings(path string, settings *RigSettings) error {
if err := validateRigSettings(settings); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("encoding settings: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: settings files don't contain secrets
return fmt.Errorf("writing settings: %w", err)
}
return nil
}
// LoadMayorConfig loads and validates a mayor config file.
func LoadMayorConfig(path string) (*MayorConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading config: %w", err)
}
var config MayorConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := validateMayorConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveMayorConfig saves a mayor config to a file.
func SaveMayorConfig(path string, config *MayorConfig) error {
if err := validateMayorConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: config files don't contain secrets
return fmt.Errorf("writing config: %w", err)
}
return nil
}
// validateMayorConfig validates a MayorConfig.
func validateMayorConfig(c *MayorConfig) error {
if c.Type != "mayor-config" && c.Type != "" {
return fmt.Errorf("%w: expected type 'mayor-config', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentMayorConfigVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentMayorConfigVersion)
}
return nil
}
// NewMayorConfig creates a new MayorConfig with defaults.
func NewMayorConfig() *MayorConfig {
return &MayorConfig{
Type: "mayor-config",
Version: CurrentMayorConfigVersion,
}
}
// DaemonPatrolConfigPath returns the path to the daemon patrol config file.
func DaemonPatrolConfigPath(townRoot string) string {
return filepath.Join(townRoot, constants.DirMayor, DaemonPatrolConfigFileName)
}
// LoadDaemonPatrolConfig loads and validates a daemon patrol config file.
func LoadDaemonPatrolConfig(path string) (*DaemonPatrolConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading daemon patrol config: %w", err)
}
var config DaemonPatrolConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing daemon patrol config: %w", err)
}
if err := validateDaemonPatrolConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveDaemonPatrolConfig saves a daemon patrol config to a file.
func SaveDaemonPatrolConfig(path string, config *DaemonPatrolConfig) error {
if err := validateDaemonPatrolConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding daemon patrol config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: config files don't contain secrets
return fmt.Errorf("writing daemon patrol config: %w", err)
}
return nil
}
func validateDaemonPatrolConfig(c *DaemonPatrolConfig) error {
if c.Type != "daemon-patrol-config" && c.Type != "" {
return fmt.Errorf("%w: expected type 'daemon-patrol-config', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentDaemonPatrolConfigVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentDaemonPatrolConfigVersion)
}
return nil
}
// EnsureDaemonPatrolConfig creates the daemon patrol config if it doesn't exist.
func EnsureDaemonPatrolConfig(townRoot string) error {
path := DaemonPatrolConfigPath(townRoot)
if _, err := os.Stat(path); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("checking daemon patrol config: %w", err)
}
return SaveDaemonPatrolConfig(path, NewDaemonPatrolConfig())
}
return nil
}
// LoadAccountsConfig loads and validates an accounts configuration file.
func LoadAccountsConfig(path string) (*AccountsConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading accounts config: %w", err)
}
var config AccountsConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing accounts config: %w", err)
}
if err := validateAccountsConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveAccountsConfig saves an accounts configuration to a file.
func SaveAccountsConfig(path string, config *AccountsConfig) error {
if err := validateAccountsConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding accounts config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: accounts config doesn't contain sensitive credentials
return fmt.Errorf("writing accounts config: %w", err)
}
return nil
}
// validateAccountsConfig validates an AccountsConfig.
func validateAccountsConfig(c *AccountsConfig) error {
if c.Version > CurrentAccountsVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentAccountsVersion)
}
if c.Accounts == nil {
c.Accounts = make(map[string]Account)
}
// Validate default refers to an existing account (if set and accounts exist)
if c.Default != "" && len(c.Accounts) > 0 {
if _, ok := c.Accounts[c.Default]; !ok {
return fmt.Errorf("%w: default account '%s' not found in accounts", ErrMissingField, c.Default)
}
}
// Validate each account has required fields
for handle, acct := range c.Accounts {
if acct.ConfigDir == "" {
return fmt.Errorf("%w: config_dir for account '%s'", ErrMissingField, handle)
}
}
return nil
}
// NewAccountsConfig creates a new AccountsConfig with defaults.
func NewAccountsConfig() *AccountsConfig {
return &AccountsConfig{
Version: CurrentAccountsVersion,
Accounts: make(map[string]Account),
}
}
// GetAccount returns an account by handle, or nil if not found.
func (c *AccountsConfig) GetAccount(handle string) *Account {
if acct, ok := c.Accounts[handle]; ok {
return &acct
}
return nil
}
// GetDefaultAccount returns the default account, or nil if not set.
func (c *AccountsConfig) GetDefaultAccount() *Account {
if c.Default == "" {
return nil
}
return c.GetAccount(c.Default)
}
// ResolveAccountConfigDir resolves the CLAUDE_CONFIG_DIR for account selection.
// Priority order:
// 1. GT_ACCOUNT environment variable
// 2. accountFlag (from --account command flag)
// 3. Default account from config
//
// Returns empty string if no account configured or resolved.
// Returns the handle that was resolved as second value.
func ResolveAccountConfigDir(accountsPath, accountFlag string) (configDir, handle string, err error) {
// Load accounts config
cfg, loadErr := LoadAccountsConfig(accountsPath)
if loadErr != nil {
// No accounts configured - that's OK, return empty
return "", "", nil
}
// Priority 1: GT_ACCOUNT env var
if envAccount := os.Getenv("GT_ACCOUNT"); envAccount != "" {
acct := cfg.GetAccount(envAccount)
if acct == nil {
return "", "", fmt.Errorf("GT_ACCOUNT '%s' not found in accounts config", envAccount)
}
return expandPath(acct.ConfigDir), envAccount, nil
}
// Priority 2: --account flag
if accountFlag != "" {
acct := cfg.GetAccount(accountFlag)
if acct == nil {
return "", "", fmt.Errorf("account '%s' not found in accounts config", accountFlag)
}
return expandPath(acct.ConfigDir), accountFlag, nil
}
// Priority 3: Default account
if cfg.Default != "" {
acct := cfg.GetDefaultAccount()
if acct != nil {
return expandPath(acct.ConfigDir), cfg.Default, nil
}
}
return "", "", nil
}
// expandPath expands ~ to home directory.
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
// LoadMessagingConfig loads and validates a messaging configuration file.
func LoadMessagingConfig(path string) (*MessagingConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading messaging config: %w", err)
}
var config MessagingConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing messaging config: %w", err)
}
if err := validateMessagingConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveMessagingConfig saves a messaging configuration to a file.
func SaveMessagingConfig(path string, config *MessagingConfig) error {
if err := validateMessagingConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding messaging config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: messaging config doesn't contain secrets
return fmt.Errorf("writing messaging config: %w", err)
}
return nil
}
// validateMessagingConfig validates a MessagingConfig.
func validateMessagingConfig(c *MessagingConfig) error {
if c.Type != "messaging" && c.Type != "" {
return fmt.Errorf("%w: expected type 'messaging', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentMessagingVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentMessagingVersion)
}
// Initialize nil maps
if c.Lists == nil {
c.Lists = make(map[string][]string)
}
if c.Queues == nil {
c.Queues = make(map[string]QueueConfig)
}
if c.Announces == nil {
c.Announces = make(map[string]AnnounceConfig)
}
if c.NudgeChannels == nil {
c.NudgeChannels = make(map[string][]string)
}
// Validate lists have at least one recipient
for name, recipients := range c.Lists {
if len(recipients) == 0 {
return fmt.Errorf("%w: list '%s' has no recipients", ErrMissingField, name)
}
}
// Validate queues have at least one worker
for name, queue := range c.Queues {
if len(queue.Workers) == 0 {
return fmt.Errorf("%w: queue '%s' workers", ErrMissingField, name)
}
if queue.MaxClaims < 0 {
return fmt.Errorf("%w: queue '%s' max_claims must be non-negative", ErrMissingField, name)
}
}
// Validate announces have at least one reader
for name, announce := range c.Announces {
if len(announce.Readers) == 0 {
return fmt.Errorf("%w: announce '%s' readers", ErrMissingField, name)
}
if announce.RetainCount < 0 {
return fmt.Errorf("%w: announce '%s' retain_count must be non-negative", ErrMissingField, name)
}
}
// Validate nudge channels have non-empty names and at least one recipient
for name, recipients := range c.NudgeChannels {
if name == "" {
return fmt.Errorf("%w: nudge channel name cannot be empty", ErrMissingField)
}
if len(recipients) == 0 {
return fmt.Errorf("%w: nudge channel '%s' has no recipients", ErrMissingField, name)
}
}
return nil
}
// MessagingConfigPath returns the standard path for messaging config in a town.
func MessagingConfigPath(townRoot string) string {
return filepath.Join(townRoot, "config", "messaging.json")
}
// LoadOrCreateMessagingConfig loads the messaging config, creating a default if not found.
func LoadOrCreateMessagingConfig(path string) (*MessagingConfig, error) {
config, err := LoadMessagingConfig(path)
if err != nil {
if errors.Is(err, ErrNotFound) {
return NewMessagingConfig(), nil
}
return nil, err
}
return config, nil
}
// LoadRuntimeConfig loads the RuntimeConfig from a rig's settings.
// Falls back to defaults if settings don't exist or don't specify runtime config.
// rigPath should be the path to the rig directory (e.g., ~/gt/gastown).
//
// Deprecated: Use ResolveAgentConfig for full agent resolution with town settings.
func LoadRuntimeConfig(rigPath string) *RuntimeConfig {
settingsPath := filepath.Join(rigPath, "settings", "config.json")
settings, err := LoadRigSettings(settingsPath)
if err != nil {
return DefaultRuntimeConfig()
}
if settings.Runtime == nil {
return DefaultRuntimeConfig()
}
return normalizeRuntimeConfig(settings.Runtime)
}
// TownSettingsPath returns the path to town settings file.
func TownSettingsPath(townRoot string) string {
return filepath.Join(townRoot, "settings", "config.json")
}
// RigSettingsPath returns the path to rig settings file.
func RigSettingsPath(rigPath string) string {
return filepath.Join(rigPath, "settings", "config.json")
}
// LoadOrCreateTownSettings loads town settings or creates defaults if missing.
func LoadOrCreateTownSettings(path string) (*TownSettings, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally
if err != nil {
if os.IsNotExist(err) {
return NewTownSettings(), nil
}
return nil, err
}
var settings TownSettings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
return &settings, nil
}
// SaveTownSettings saves town settings to a file.
func SaveTownSettings(path string, settings *TownSettings) error {
if settings.Type != "town-settings" && settings.Type != "" {
return fmt.Errorf("%w: expected type 'town-settings', got '%s'", ErrInvalidType, settings.Type)
}
if settings.Version > CurrentTownSettingsVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, settings.Version, CurrentTownSettingsVersion)
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("encoding settings: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: settings files don't contain secrets
return fmt.Errorf("writing settings: %w", err)
}
return nil
}
// ResolveAgentConfig resolves the agent configuration for a rig.
// It looks up the agent by name in town settings (custom agents) and built-in presets.
//
// Resolution order:
// 1. If rig has Runtime set directly, use it (backwards compatibility)
// 2. If rig has Agent set, look it up in:
// a. Town's custom agents (from TownSettings.Agents)
// b. Built-in presets (claude, gemini, codex)
// 3. If rig has no Agent set, use town's default_agent
// 4. Fall back to claude defaults
//
// townRoot is the path to the town directory (e.g., ~/gt).
// rigPath is the path to the rig directory (e.g., ~/gt/gastown).
func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig {
// Load rig settings
rigSettings, err := LoadRigSettings(RigSettingsPath(rigPath))
if err != nil {
rigSettings = nil
}
// Backwards compatibility: if Runtime is set directly, use it
if rigSettings != nil && rigSettings.Runtime != nil {
rc := rigSettings.Runtime
return fillRuntimeDefaults(rc)
}
// Load town settings for agent lookup
townSettings, err := LoadOrCreateTownSettings(TownSettingsPath(townRoot))
if err != nil {
townSettings = NewTownSettings()
}
// Load custom agent registry if it exists
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
// Load rig-level custom agent registry if it exists (for per-rig custom agents)
_ = LoadRigAgentRegistry(RigAgentRegistryPath(rigPath))
// Determine which agent name to use
agentName := ""
if rigSettings != nil && rigSettings.Agent != "" {
agentName = rigSettings.Agent
} else if townSettings.DefaultAgent != "" {
agentName = townSettings.DefaultAgent
} else {
agentName = "claude" // ultimate fallback
}
return lookupAgentConfig(agentName, townSettings, rigSettings)
}
// ResolveAgentConfigWithOverride resolves the agent configuration for a rig, with an optional override.
// If agentOverride is non-empty, it is used instead of rig/town defaults.
// Returns the resolved RuntimeConfig, the selected agent name, and an error if the override name
// does not exist in town custom agents or built-in presets.
func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*RuntimeConfig, string, error) {
// Load rig settings
rigSettings, err := LoadRigSettings(RigSettingsPath(rigPath))
if err != nil {
rigSettings = nil
}
// Backwards compatibility: if Runtime is set directly, use it (but still report agentOverride if present)
if rigSettings != nil && rigSettings.Runtime != nil && agentOverride == "" {
rc := rigSettings.Runtime
return fillRuntimeDefaults(rc), "", nil
}
// Load town settings for agent lookup
townSettings, err := LoadOrCreateTownSettings(TownSettingsPath(townRoot))
if err != nil {
townSettings = NewTownSettings()
}
// Load custom agent registry if it exists
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
// Load rig-level custom agent registry if it exists (for per-rig custom agents)
_ = LoadRigAgentRegistry(RigAgentRegistryPath(rigPath))
// Determine which agent name to use
agentName := ""
if agentOverride != "" {
agentName = agentOverride
} else if rigSettings != nil && rigSettings.Agent != "" {
agentName = rigSettings.Agent
} else if townSettings.DefaultAgent != "" {
agentName = townSettings.DefaultAgent
} else {
agentName = "claude" // ultimate fallback
}
// If an override is requested, validate it exists
if agentOverride != "" {
// Check rig-level custom agents first
if rigSettings != nil && rigSettings.Agents != nil {
if custom, ok := rigSettings.Agents[agentName]; ok && custom != nil {
return fillRuntimeDefaults(custom), agentName, nil
}
}
// Then check town-level custom agents
if townSettings.Agents != nil {
if custom, ok := townSettings.Agents[agentName]; ok && custom != nil {
return fillRuntimeDefaults(custom), agentName, nil
}
}
// Then check built-in presets
if preset := GetAgentPresetByName(agentName); preset != nil {
return RuntimeConfigFromPreset(AgentPreset(agentName)), agentName, nil
}
return nil, "", fmt.Errorf("agent '%s' not found", agentName)
}
// Normal lookup path (no override)
return lookupAgentConfig(agentName, townSettings, rigSettings), agentName, nil
}
// ValidateAgentConfig checks if an agent configuration is valid and the binary exists.
// Returns an error describing the issue, or nil if valid.
func ValidateAgentConfig(agentName string, townSettings *TownSettings, rigSettings *RigSettings) error {
// Check if agent exists in config
rc := lookupAgentConfigIfExists(agentName, townSettings, rigSettings)
if rc == nil {
return fmt.Errorf("agent %q not found in config or built-in presets", agentName)
}
// Check if binary exists on system
if _, err := exec.LookPath(rc.Command); err != nil {
return fmt.Errorf("agent %q binary %q not found in PATH", agentName, rc.Command)
}
return nil
}
// lookupAgentConfigIfExists looks up an agent by name but returns nil if not found
// (instead of falling back to default). Used for validation.
func lookupAgentConfigIfExists(name string, townSettings *TownSettings, rigSettings *RigSettings) *RuntimeConfig {
// Check rig's custom agents
if rigSettings != nil && rigSettings.Agents != nil {
if custom, ok := rigSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)
}
}
// Check town's custom agents
if townSettings != nil && townSettings.Agents != nil {
if custom, ok := townSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)
}
}
// Check built-in presets
if preset := GetAgentPresetByName(name); preset != nil {
return RuntimeConfigFromPreset(AgentPreset(name))
}
return nil
}
// ResolveRoleAgentConfig resolves the agent configuration for a specific role.
// It checks role-specific agent assignments before falling back to the default agent.
//
// Resolution order:
// 1. Rig's RoleAgents[role] - if set, look up that agent
// 2. Town's RoleAgents[role] - if set, look up that agent
// 3. Fall back to ResolveAgentConfig (rig's Agent → town's DefaultAgent → "claude")
//
// If a configured agent is not found or its binary doesn't exist, a warning is
// printed to stderr and it falls back to the default agent.
//
// role is one of: "mayor", "deacon", "witness", "refinery", "polecat", "crew".
// townRoot is the path to the town directory (e.g., ~/gt).
// rigPath is the path to the rig directory (e.g., ~/gt/gastown), or empty for town-level roles.
func ResolveRoleAgentConfig(role, townRoot, rigPath string) *RuntimeConfig {
// Load rig settings (may be nil for town-level roles like mayor/deacon)
var rigSettings *RigSettings
if rigPath != "" {
var err error
rigSettings, err = LoadRigSettings(RigSettingsPath(rigPath))
if err != nil {
rigSettings = nil
}
}
// Load town settings
townSettings, err := LoadOrCreateTownSettings(TownSettingsPath(townRoot))
if err != nil {
townSettings = NewTownSettings()
}
// Load custom agent registries
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
if rigPath != "" {
_ = LoadRigAgentRegistry(RigAgentRegistryPath(rigPath))
}
// Check rig's RoleAgents first
if rigSettings != nil && rigSettings.RoleAgents != nil {
if agentName, ok := rigSettings.RoleAgents[role]; ok && agentName != "" {
if err := ValidateAgentConfig(agentName, townSettings, rigSettings); err != nil {
fmt.Fprintf(os.Stderr, "warning: role_agents[%s]=%s - %v, falling back to default\n", role, agentName, err)
} else {
return lookupAgentConfig(agentName, townSettings, rigSettings)
}
}
}
// Check town's RoleAgents
if townSettings.RoleAgents != nil {
if agentName, ok := townSettings.RoleAgents[role]; ok && agentName != "" {
if err := ValidateAgentConfig(agentName, townSettings, rigSettings); err != nil {
fmt.Fprintf(os.Stderr, "warning: role_agents[%s]=%s - %v, falling back to default\n", role, agentName, err)
} else {
return lookupAgentConfig(agentName, townSettings, rigSettings)
}
}
}
// Fall back to existing resolution (rig's Agent → town's DefaultAgent → "claude")
return ResolveAgentConfig(townRoot, rigPath)
}
// ResolveRoleAgentName returns the agent name that would be used for a specific role.
// This is useful for logging and diagnostics.
// Returns the agent name and whether it came from role-specific configuration.
func ResolveRoleAgentName(role, townRoot, rigPath string) (agentName string, isRoleSpecific bool) {
// Load rig settings
var rigSettings *RigSettings
if rigPath != "" {
var err error
rigSettings, err = LoadRigSettings(RigSettingsPath(rigPath))
if err != nil {
rigSettings = nil
}
}
// Load town settings
townSettings, err := LoadOrCreateTownSettings(TownSettingsPath(townRoot))
if err != nil {
townSettings = NewTownSettings()
}
// Check rig's RoleAgents first
if rigSettings != nil && rigSettings.RoleAgents != nil {
if name, ok := rigSettings.RoleAgents[role]; ok && name != "" {
return name, true
}
}
// Check town's RoleAgents
if townSettings.RoleAgents != nil {
if name, ok := townSettings.RoleAgents[role]; ok && name != "" {
return name, true
}
}
// Fall back to existing resolution
if rigSettings != nil && rigSettings.Agent != "" {
return rigSettings.Agent, false
}
if townSettings.DefaultAgent != "" {
return townSettings.DefaultAgent, false
}
return "claude", false
}
// lookupAgentConfig looks up an agent by name.
// Checks rig-level custom agents first, then town's custom agents, then built-in presets from agents.go.
func lookupAgentConfig(name string, townSettings *TownSettings, rigSettings *RigSettings) *RuntimeConfig {
// First check rig's custom agents (NEW - fix for rig-level agent support)
if rigSettings != nil && rigSettings.Agents != nil {
if custom, ok := rigSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)
}
}
// Then check town's custom agents (existing)
if townSettings != nil && townSettings.Agents != nil {
if custom, ok := townSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)
}
}
// Check built-in presets from agents.go
if preset := GetAgentPresetByName(name); preset != nil {
return RuntimeConfigFromPreset(AgentPreset(name))
}
// Fallback to claude defaults
return DefaultRuntimeConfig()
}
// fillRuntimeDefaults fills in default values for empty RuntimeConfig fields.
func fillRuntimeDefaults(rc *RuntimeConfig) *RuntimeConfig {
if rc == nil {
return DefaultRuntimeConfig()
}
// Create a copy to avoid modifying the original
result := &RuntimeConfig{
Command: rc.Command,
Args: rc.Args,
InitialPrompt: rc.InitialPrompt,
}
if result.Command == "" {
result.Command = "claude"
}
if result.Args == nil {
result.Args = []string{"--dangerously-skip-permissions"}
}
return result
}
// GetRuntimeCommand is a convenience function that returns the full command string
// for starting an LLM session. It resolves the agent config and builds the command.
func GetRuntimeCommand(rigPath string) string {
if rigPath == "" {
// Try to detect town root from cwd for town-level agents (mayor, deacon)
townRoot, err := findTownRootFromCwd()
if err != nil {
return DefaultRuntimeConfig().BuildCommand()
}
return ResolveAgentConfig(townRoot, "").BuildCommand()
}
// Derive town root from rig path (rig is typically ~/gt/<rigname>)
townRoot := filepath.Dir(rigPath)
return ResolveAgentConfig(townRoot, rigPath).BuildCommand()
}
// GetRuntimeCommandWithAgentOverride returns the full command for starting an LLM session,
// using agentOverride if non-empty.
func GetRuntimeCommandWithAgentOverride(rigPath, agentOverride string) (string, error) {
if rigPath == "" {
townRoot, err := findTownRootFromCwd()
if err != nil {
return DefaultRuntimeConfig().BuildCommand(), nil
}
rc, _, resolveErr := ResolveAgentConfigWithOverride(townRoot, "", agentOverride)
if resolveErr != nil {
return "", resolveErr
}
return rc.BuildCommand(), nil
}
townRoot := filepath.Dir(rigPath)
rc, _, err := ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride)
if err != nil {
return "", err
}
return rc.BuildCommand(), nil
}
// GetRuntimeCommandWithPrompt returns the full command with an initial prompt.
func GetRuntimeCommandWithPrompt(rigPath, prompt string) string {
if rigPath == "" {
// Try to detect town root from cwd for town-level agents (mayor, deacon)
townRoot, err := findTownRootFromCwd()
if err != nil {
return DefaultRuntimeConfig().BuildCommandWithPrompt(prompt)
}
return ResolveAgentConfig(townRoot, "").BuildCommandWithPrompt(prompt)
}
townRoot := filepath.Dir(rigPath)
return ResolveAgentConfig(townRoot, rigPath).BuildCommandWithPrompt(prompt)
}
// GetRuntimeCommandWithPromptAndAgentOverride returns the full command with an initial prompt,
// using agentOverride if non-empty.
func GetRuntimeCommandWithPromptAndAgentOverride(rigPath, prompt, agentOverride string) (string, error) {
if rigPath == "" {
townRoot, err := findTownRootFromCwd()
if err != nil {
return DefaultRuntimeConfig().BuildCommandWithPrompt(prompt), nil
}
rc, _, resolveErr := ResolveAgentConfigWithOverride(townRoot, "", agentOverride)
if resolveErr != nil {
return "", resolveErr
}
return rc.BuildCommandWithPrompt(prompt), nil
}
townRoot := filepath.Dir(rigPath)
rc, _, err := ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride)
if err != nil {
return "", err
}
return rc.BuildCommandWithPrompt(prompt), nil
}
// findTownRootFromCwd locates the town root by walking up from cwd.
// It looks for the mayor/town.json marker file.
// Returns empty string and no error if not found (caller should use defaults).
func findTownRootFromCwd() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("getting cwd: %w", err)
}
absDir, err := filepath.Abs(cwd)
if err != nil {
return "", fmt.Errorf("resolving path: %w", err)
}
const marker = "mayor/town.json"
current := absDir
for {
if _, err := os.Stat(filepath.Join(current, marker)); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", fmt.Errorf("town root not found (no %s marker)", marker)
}
current = parent
}
}
// BuildStartupCommand builds a full startup command with environment exports.
// envVars is a map of environment variable names to values.
// rigPath is optional - if empty, tries to detect town root from cwd.
// prompt is optional - if provided, appended as the initial prompt.
//
// If envVars contains GT_ROLE, the function uses role-based agent resolution
// (ResolveRoleAgentConfig) to select the appropriate agent for the role.
// This enables per-role model selection via role_agents in settings.
func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string {
var rc *RuntimeConfig
var townRoot string
// Extract role from envVars for role-based agent resolution
role := envVars["GT_ROLE"]
if rigPath != "" {
// Derive town root from rig path
townRoot = filepath.Dir(rigPath)
if role != "" {
// Use role-based agent resolution for per-role model selection
rc = ResolveRoleAgentConfig(role, townRoot, rigPath)
} else {
rc = ResolveAgentConfig(townRoot, rigPath)
}
} else {
// Try to detect town root from cwd for town-level agents (mayor, deacon)
var err error
townRoot, err = findTownRootFromCwd()
if err != nil {
rc = DefaultRuntimeConfig()
} else {
if role != "" {
// Use role-based agent resolution for per-role model selection
rc = ResolveRoleAgentConfig(role, townRoot, "")
} else {
rc = ResolveAgentConfig(townRoot, "")
}
}
}
// Copy env vars to avoid mutating caller map
resolvedEnv := make(map[string]string, len(envVars)+2)
for k, v := range envVars {
resolvedEnv[k] = v
}
// Add GT_ROOT so agents can find town-level resources (formulas, etc.)
if townRoot != "" {
resolvedEnv["GT_ROOT"] = townRoot
}
if rc.Session != nil && rc.Session.SessionIDEnv != "" {
resolvedEnv["GT_SESSION_ID_ENV"] = rc.Session.SessionIDEnv
}
// Build environment export prefix
var exports []string
for k, v := range resolvedEnv {
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
}
// Sort for deterministic output
sort.Strings(exports)
var cmd string
if len(exports) > 0 {
cmd = "export " + strings.Join(exports, " ") + " && "
}
// Add runtime command
if prompt != "" {
cmd += rc.BuildCommandWithPrompt(prompt)
} else {
cmd += rc.BuildCommand()
}
return cmd
}
// PrependEnv prepends export statements to a command string.
func PrependEnv(command string, envVars map[string]string) string {
if len(envVars) == 0 {
return command
}
var exports []string
for k, v := range envVars {
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
}
sort.Strings(exports)
return "export " + strings.Join(exports, " ") + " && " + command
}
// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand,
// but uses agentOverride if non-empty.
//
// Resolution priority:
// 1. agentOverride (explicit override)
// 2. role_agents[GT_ROLE] (if GT_ROLE is in envVars)
// 3. Default agent resolution (rig's Agent → town's DefaultAgent → "claude")
func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) {
var rc *RuntimeConfig
var townRoot string
// Extract role from envVars for role-based agent resolution (when no override)
role := envVars["GT_ROLE"]
if rigPath != "" {
townRoot = filepath.Dir(rigPath)
if agentOverride != "" {
var err error
rc, _, err = ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride)
if err != nil {
return "", err
}
} else if role != "" {
// No override, use role-based agent resolution
rc = ResolveRoleAgentConfig(role, townRoot, rigPath)
} else {
rc = ResolveAgentConfig(townRoot, rigPath)
}
} else {
var err error
townRoot, err = findTownRootFromCwd()
if err != nil {
rc = DefaultRuntimeConfig()
} else {
if agentOverride != "" {
var resolveErr error
rc, _, resolveErr = ResolveAgentConfigWithOverride(townRoot, "", agentOverride)
if resolveErr != nil {
return "", resolveErr
}
} else if role != "" {
// No override, use role-based agent resolution
rc = ResolveRoleAgentConfig(role, townRoot, "")
} else {
rc = ResolveAgentConfig(townRoot, "")
}
}
}
// Copy env vars to avoid mutating caller map
resolvedEnv := make(map[string]string, len(envVars)+2)
for k, v := range envVars {
resolvedEnv[k] = v
}
// Add GT_ROOT so agents can find town-level resources (formulas, etc.)
if townRoot != "" {
resolvedEnv["GT_ROOT"] = townRoot
}
if rc.Session != nil && rc.Session.SessionIDEnv != "" {
resolvedEnv["GT_SESSION_ID_ENV"] = rc.Session.SessionIDEnv
}
// Build environment export prefix
var exports []string
for k, v := range resolvedEnv {
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
}
sort.Strings(exports)
var cmd string
if len(exports) > 0 {
cmd = "export " + strings.Join(exports, " ") + " && "
}
if prompt != "" {
cmd += rc.BuildCommandWithPrompt(prompt)
} else {
cmd += rc.BuildCommand()
}
return cmd, nil
}
// BuildAgentStartupCommand is a convenience function for starting agent sessions.
// It uses AgentEnv to set all standard environment variables.
// For rig-level roles (witness, refinery), pass the rig name and rigPath.
// For town-level roles (mayor, deacon, boot), pass empty rig and rigPath, but provide townRoot.
func BuildAgentStartupCommand(role, rig, townRoot, rigPath, prompt string) string {
envVars := AgentEnv(AgentEnvConfig{
Role: role,
Rig: rig,
TownRoot: townRoot,
})
return BuildStartupCommand(envVars, rigPath, prompt)
}
// BuildAgentStartupCommandWithAgentOverride is like BuildAgentStartupCommand, but uses agentOverride if non-empty.
func BuildAgentStartupCommandWithAgentOverride(role, rig, townRoot, rigPath, prompt, agentOverride string) (string, error) {
envVars := AgentEnv(AgentEnvConfig{
Role: role,
Rig: rig,
TownRoot: townRoot,
})
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
}
// BuildPolecatStartupCommand builds the startup command for a polecat.
// Sets GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) string {
var townRoot string
if rigPath != "" {
townRoot = filepath.Dir(rigPath)
}
envVars := AgentEnv(AgentEnvConfig{
Role: "polecat",
Rig: rigName,
AgentName: polecatName,
TownRoot: townRoot,
})
return BuildStartupCommand(envVars, rigPath, prompt)
}
// BuildPolecatStartupCommandWithAgentOverride is like BuildPolecatStartupCommand, but uses agentOverride if non-empty.
func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath, prompt, agentOverride string) (string, error) {
var townRoot string
if rigPath != "" {
townRoot = filepath.Dir(rigPath)
}
envVars := AgentEnv(AgentEnvConfig{
Role: "polecat",
Rig: rigName,
AgentName: polecatName,
TownRoot: townRoot,
})
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
}
// BuildCrewStartupCommand builds the startup command for a crew member.
// Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string {
var townRoot string
if rigPath != "" {
townRoot = filepath.Dir(rigPath)
}
envVars := AgentEnv(AgentEnvConfig{
Role: "crew",
Rig: rigName,
AgentName: crewName,
TownRoot: townRoot,
})
return BuildStartupCommand(envVars, rigPath, prompt)
}
// BuildCrewStartupCommandWithAgentOverride is like BuildCrewStartupCommand, but uses agentOverride if non-empty.
func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt, agentOverride string) (string, error) {
var townRoot string
if rigPath != "" {
townRoot = filepath.Dir(rigPath)
}
envVars := AgentEnv(AgentEnvConfig{
Role: "crew",
Rig: rigName,
AgentName: crewName,
TownRoot: townRoot,
})
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
}
// ExpectedPaneCommands returns tmux pane command names that indicate the runtime is running.
// For example, Claude runs as "node", while most other runtimes report their executable name.
func ExpectedPaneCommands(rc *RuntimeConfig) []string {
if rc == nil || rc.Command == "" {
return nil
}
if filepath.Base(rc.Command) == "claude" {
return []string{"node"}
}
return []string{filepath.Base(rc.Command)}
}
// GetDefaultFormula returns the default formula for a rig from settings/config.json.
// Returns empty string if no default is configured.
// rigPath is the path to the rig directory (e.g., ~/gt/gastown).
func GetDefaultFormula(rigPath string) string {
settingsPath := RigSettingsPath(rigPath)
settings, err := LoadRigSettings(settingsPath)
if err != nil {
return ""
}
if settings.Workflow == nil {
return ""
}
return settings.Workflow.DefaultFormula
}
// GetRigPrefix returns the beads prefix for a rig from rigs.json.
// Falls back to "gt" if the rig isn't found or has no prefix configured.
// townRoot is the path to the town directory (e.g., ~/gt).
func GetRigPrefix(townRoot, rigName string) string {
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := LoadRigsConfig(rigsConfigPath)
if err != nil {
return "gt" // fallback
}
entry, ok := rigsConfig.Rigs[rigName]
if !ok {
return "gt" // fallback
}
if entry.BeadsConfig == nil || entry.BeadsConfig.Prefix == "" {
return "gt" // fallback
}
// Strip trailing hyphen if present (prefix stored as "gt-" but used as "gt")
prefix := entry.BeadsConfig.Prefix
return strings.TrimSuffix(prefix, "-")
}
// EscalationConfigPath returns the standard path for escalation config in a town.
func EscalationConfigPath(townRoot string) string {
return filepath.Join(townRoot, "settings", "escalation.json")
}
// LoadEscalationConfig loads and validates an escalation configuration file.
func LoadEscalationConfig(path string) (*EscalationConfig, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading escalation config: %w", err)
}
var config EscalationConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing escalation config: %w", err)
}
if err := validateEscalationConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// LoadOrCreateEscalationConfig loads the escalation config, creating a default if not found.
func LoadOrCreateEscalationConfig(path string) (*EscalationConfig, error) {
config, err := LoadEscalationConfig(path)
if err != nil {
if errors.Is(err, ErrNotFound) {
return NewEscalationConfig(), nil
}
return nil, err
}
return config, nil
}
// SaveEscalationConfig saves an escalation configuration to a file.
func SaveEscalationConfig(path string, config *EscalationConfig) error {
if err := validateEscalationConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding escalation config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: escalation config doesn't contain secrets
return fmt.Errorf("writing escalation config: %w", err)
}
return nil
}
// validateEscalationConfig validates an EscalationConfig.
func validateEscalationConfig(c *EscalationConfig) error {
if c.Type != "escalation" && c.Type != "" {
return fmt.Errorf("%w: expected type 'escalation', got '%s'", ErrInvalidType, c.Type)
}
if c.Version > CurrentEscalationVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentEscalationVersion)
}
// Validate stale_threshold if specified
if c.StaleThreshold != "" {
if _, err := time.ParseDuration(c.StaleThreshold); err != nil {
return fmt.Errorf("invalid stale_threshold: %w", err)
}
}
// Initialize nil maps
if c.Routes == nil {
c.Routes = make(map[string][]string)
}
// Validate severity route keys
for severity := range c.Routes {
if !IsValidSeverity(severity) {
return fmt.Errorf("%w: unknown severity '%s' (valid: low, medium, high, critical)", ErrMissingField, severity)
}
}
// Validate max_reescalations is non-negative
if c.MaxReescalations < 0 {
return fmt.Errorf("%w: max_reescalations must be non-negative", ErrMissingField)
}
return nil
}
// GetStaleThreshold returns the stale threshold as a time.Duration.
// Returns 4 hours if not configured or invalid.
func (c *EscalationConfig) GetStaleThreshold() time.Duration {
if c.StaleThreshold == "" {
return 4 * time.Hour
}
d, err := time.ParseDuration(c.StaleThreshold)
if err != nil {
return 4 * time.Hour
}
return d
}
// GetRouteForSeverity returns the escalation route actions for a given severity.
// Falls back to ["bead", "mail:mayor"] if no specific route is configured.
func (c *EscalationConfig) GetRouteForSeverity(severity string) []string {
if route, ok := c.Routes[severity]; ok {
return route
}
// Fallback to default route
return []string{"bead", "mail:mayor"}
}
// GetMaxReescalations returns the maximum number of re-escalations allowed.
// Returns 2 if not configured.
func (c *EscalationConfig) GetMaxReescalations() int {
if c.MaxReescalations <= 0 {
return 2
}
return c.MaxReescalations
}