Adds GT_AGENT env var to track agent override when using --agent flag. Handoff reads and preserves GT_AGENT so non-default agents persist across restarts. Co-authored-by: joshuavial <git@codewithjv.com>
1646 lines
50 KiB
Go
1646 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
|
|
}
|
|
// Record agent override so handoff can preserve it
|
|
if agentOverride != "" {
|
|
resolvedEnv["GT_AGENT"] = agentOverride
|
|
}
|
|
|
|
// 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
|
|
}
|