diff --git a/internal/cmd/gitinit.go b/internal/cmd/gitinit.go index 6947ee8b..3bb0b14f 100644 --- a/internal/cmd/gitinit.go +++ b/internal/cmd/gitinit.go @@ -79,9 +79,9 @@ const HQGitignore = `# Gas Town HQ .gitignore **/crew/ # ============================================================================= -# Rig runtime state directories +# Runtime state directories (gitignored ephemeral data) # ============================================================================= -**/.gastown/ +**/.runtime/ # ============================================================================= # Rig .beads symlinks (point to ignored mayor/rig/.beads, recreated on setup) diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index 30ff5023..dfb9f663 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -480,7 +480,7 @@ func setRequestingState(role Role, action HandoffAction, townRoot string) error } default: // For other roles, use a generic location - stateFile = filepath.Join(townRoot, ".gastown", "agent-state.json") + stateFile = filepath.Join(townRoot, ".runtime", "agent-state.json") } // Ensure directory exists diff --git a/internal/cmd/mq_integration.go b/internal/cmd/mq_integration.go index ed6c93d0..b9fa0072 100644 --- a/internal/cmd/mq_integration.go +++ b/internal/cmd/mq_integration.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" @@ -397,33 +398,17 @@ func filterMRsByTarget(mrs []*beads.Issue, targetBranch string) []*beads.Issue { return result } -// getTestCommand returns the test command from rig config. +// getTestCommand returns the test command from rig settings. func getTestCommand(rigPath string) string { - configPath := filepath.Join(rigPath, "config.json") - data, err := os.ReadFile(configPath) + settingsPath := filepath.Join(rigPath, "settings", "config.json") + settings, err := config.LoadRigSettings(settingsPath) if err != nil { - // Try .gastown/config.json as fallback - configPath = filepath.Join(rigPath, ".gastown", "config.json") - data, err = os.ReadFile(configPath) - if err != nil { - return "" - } - } - - var rawConfig struct { - MergeQueue struct { - TestCommand string `json:"test_command"` - } `json:"merge_queue"` - TestCommand string `json:"test_command"` // Legacy fallback - } - if err := json.Unmarshal(data, &rawConfig); err != nil { return "" } - - if rawConfig.MergeQueue.TestCommand != "" { - return rawConfig.MergeQueue.TestCommand + if settings.MergeQueue != nil && settings.MergeQueue.TestCommand != "" { + return settings.MergeQueue.TestCommand } - return rawConfig.TestCommand + return "" } // runTestCommand executes a test command in the given directory. diff --git a/internal/cmd/namepool.go b/internal/cmd/namepool.go index 9b19d357..091af0c8 100644 --- a/internal/cmd/namepool.go +++ b/internal/cmd/namepool.go @@ -104,9 +104,9 @@ func runNamepool(cmd *cobra.Command, args []string) error { } // Check if configured - configPath := filepath.Join(rigPath, ".gastown", "config.json") - if cfg, err := config.LoadRigConfig(configPath); err == nil && cfg.Namepool != nil { - fmt.Printf("(configured in .gastown/config.json)\n") + settingsPath := filepath.Join(rigPath, "settings", "config.json") + if settings, err := config.LoadRigSettings(settingsPath); err == nil && settings.Namepool != nil { + fmt.Printf("(configured in settings/config.json)\n") } return nil @@ -271,39 +271,31 @@ func detectCurrentRigWithPath() (string, string) { return "", "" } -// saveRigNamepoolConfig saves the namepool config to rig config. +// saveRigNamepoolConfig saves the namepool config to rig settings. func saveRigNamepoolConfig(rigPath, theme string, customNames []string) error { - configPath := filepath.Join(rigPath, ".gastown", "config.json") + settingsPath := filepath.Join(rigPath, "settings", "config.json") - // Load existing config or create new - var cfg *config.RigConfig - cfg, err := config.LoadRigConfig(configPath) + // Load existing settings or create new + var settings *config.RigSettings + settings, err := config.LoadRigSettings(settingsPath) if err != nil { - // Create new config if not found + // Create new settings if not found if os.IsNotExist(err) || strings.Contains(err.Error(), "not found") { - cfg = &config.RigConfig{ - Type: "rig", - Version: config.CurrentRigConfigVersion, - } + settings = config.NewRigSettings() } else { - return fmt.Errorf("loading config: %w", err) + return fmt.Errorf("loading settings: %w", err) } } // Set namepool - cfg.Namepool = &config.NamepoolConfig{ + settings.Namepool = &config.NamepoolConfig{ Style: theme, Names: customNames, } - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { - return err - } - - // Save - if err := config.SaveRigConfig(configPath, cfg); err != nil { - return fmt.Errorf("saving config: %w", err) + // Save (creates directory if needed) + if err := config.SaveRigSettings(settingsPath, settings); err != nil { + return fmt.Errorf("saving settings: %w", err) } return nil diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 0ec78a87..a7faecb2 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -715,7 +715,7 @@ func acquireIdentityLock(ctx RoleContext) error { fmt.Printf("To resolve:\n") fmt.Printf(" 1. Find the other session and close it, OR\n") fmt.Printf(" 2. Run: gt doctor --fix (cleans stale locks)\n") - fmt.Printf(" 3. If lock is stale: rm %s/.gastown/agent.lock\n", ctx.WorkDir) + fmt.Printf(" 3. If lock is stale: rm %s/.runtime/agent.lock\n", ctx.WorkDir) fmt.Println() return fmt.Errorf("cannot claim identity %s/%s: %w", ctx.Rig, ctx.Polecat, err) diff --git a/internal/cmd/swarm.go b/internal/cmd/swarm.go index e2e8a3a8..7bf38d45 100644 --- a/internal/cmd/swarm.go +++ b/internal/cmd/swarm.go @@ -147,7 +147,7 @@ type SwarmStore struct { // LoadSwarmStore loads swarm state from disk. func LoadSwarmStore(rigPath string) (*SwarmStore, error) { - storePath := filepath.Join(rigPath, ".gastown", "swarms.json") + storePath := filepath.Join(rigPath, ".runtime", "swarms.json") store := &SwarmStore{ path: storePath, Swarms: make(map[string]*swarm.Swarm), diff --git a/internal/cmd/theme.go b/internal/cmd/theme.go index 355878c8..4519aeac 100644 --- a/internal/cmd/theme.go +++ b/internal/cmd/theme.go @@ -78,7 +78,7 @@ func runTheme(cmd *cobra.Command, args []string) error { fmt.Printf("Theme: %s (%s)\n", theme.Name, theme.Style()) // Show if it's configured vs default if configured := loadRigTheme(rigName); configured != "" { - fmt.Printf("(configured in .gastown/config.json)\n") + fmt.Printf("(configured in settings/config.json)\n") } else { fmt.Printf("(default, based on rig name hash)\n") } @@ -254,8 +254,8 @@ func getThemeForRig(rigName string) tmux.Theme { // getThemeForRole returns the theme for a specific role in a rig. // Resolution order: -// 1. Per-rig role override (rig/.gastown/config.json) -// 2. Global role default (mayor/town.json) +// 1. Per-rig role override (rig/settings/config.json) +// 2. Global role default (mayor/config.json) // 3. Built-in role defaults (witness=rust, refinery=plum) // 4. Rig theme (config or hash-based) func getThemeForRole(rigName, role string) tmux.Theme { @@ -263,10 +263,10 @@ func getThemeForRole(rigName, role string) tmux.Theme { // 1. Check per-rig role override if townRoot != "" { - configPath := filepath.Join(townRoot, rigName, ".gastown", "config.json") - if cfg, err := config.LoadRigConfig(configPath); err == nil { - if cfg.Theme != nil && cfg.Theme.RoleThemes != nil { - if themeName, ok := cfg.Theme.RoleThemes[role]; ok { + settingsPath := filepath.Join(townRoot, rigName, "settings", "config.json") + if settings, err := config.LoadRigSettings(settingsPath); err == nil { + if settings.Theme != nil && settings.Theme.RoleThemes != nil { + if themeName, ok := settings.Theme.RoleThemes[role]; ok { if theme := tmux.GetThemeByName(themeName); theme != nil { return *theme } @@ -275,12 +275,12 @@ func getThemeForRole(rigName, role string) tmux.Theme { } } - // 2. Check global role default (town config) + // 2. Check global role default (mayor config) if townRoot != "" { - townConfigPath := filepath.Join(townRoot, "mayor", "town.json") - if townCfg, err := config.LoadTownConfig(townConfigPath); err == nil { - if townCfg.Theme != nil && townCfg.Theme.RoleDefaults != nil { - if themeName, ok := townCfg.Theme.RoleDefaults[role]; ok { + mayorConfigPath := filepath.Join(townRoot, "mayor", "config.json") + if mayorCfg, err := config.LoadMayorConfig(mayorConfigPath); err == nil { + if mayorCfg.Theme != nil && mayorCfg.Theme.RoleDefaults != nil { + if themeName, ok := mayorCfg.Theme.RoleDefaults[role]; ok { if theme := tmux.GetThemeByName(themeName); theme != nil { return *theme } @@ -301,26 +301,26 @@ func getThemeForRole(rigName, role string) tmux.Theme { return getThemeForRig(rigName) } -// loadRigTheme loads the theme name from rig config. +// loadRigTheme loads the theme name from rig settings. func loadRigTheme(rigName string) string { townRoot, err := workspace.FindFromCwd() if err != nil || townRoot == "" { return "" } - configPath := filepath.Join(townRoot, rigName, ".gastown", "config.json") - cfg, err := config.LoadRigConfig(configPath) + settingsPath := filepath.Join(townRoot, rigName, "settings", "config.json") + settings, err := config.LoadRigSettings(settingsPath) if err != nil { return "" } - if cfg.Theme != nil && cfg.Theme.Name != "" { - return cfg.Theme.Name + if settings.Theme != nil && settings.Theme.Name != "" { + return settings.Theme.Name } return "" } -// saveRigTheme saves the theme name to rig config. +// saveRigTheme saves the theme name to rig settings. func saveRigTheme(rigName, themeName string) error { townRoot, err := workspace.FindFromCwd() if err != nil { @@ -330,31 +330,28 @@ func saveRigTheme(rigName, themeName string) error { return fmt.Errorf("not in a Gas Town workspace") } - configPath := filepath.Join(townRoot, rigName, ".gastown", "config.json") + settingsPath := filepath.Join(townRoot, rigName, "settings", "config.json") - // Load existing config or create new - var cfg *config.RigConfig - cfg, err = config.LoadRigConfig(configPath) + // Load existing settings or create new + var settings *config.RigSettings + settings, err = config.LoadRigSettings(settingsPath) if err != nil { - // Create new config if not found + // Create new settings if not found if os.IsNotExist(err) || strings.Contains(err.Error(), "not found") { - cfg = &config.RigConfig{ - Type: "rig", - Version: config.CurrentRigConfigVersion, - } + settings = config.NewRigSettings() } else { - return fmt.Errorf("loading config: %w", err) + return fmt.Errorf("loading settings: %w", err) } } // Set theme - cfg.Theme = &config.ThemeConfig{ + settings.Theme = &config.ThemeConfig{ Name: themeName, } // Save - if err := config.SaveRigConfig(configPath, cfg); err != nil { - return fmt.Errorf("saving config: %w", err) + if err := config.SaveRigSettings(settingsPath, settings); err != nil { + return fmt.Errorf("saving settings: %w", err) } return nil diff --git a/internal/config/loader.go b/internal/config/loader.go index 885d279f..7e018bd1 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -232,7 +232,7 @@ func SaveRigConfig(path string, config *RigConfig) error { return nil } -// validateRigConfig validates a RigConfig. +// 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) @@ -240,14 +240,25 @@ func validateRigConfig(c *RigConfig) error { 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 +} - // Validate merge queue config if present +// 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 } @@ -280,11 +291,129 @@ func validateMergeQueueConfig(c *MergeQueueConfig) error { return nil } -// NewRigConfig creates a new RigConfig with defaults. -func NewRigConfig() *RigConfig { +// NewRigConfig creates a new RigConfig (identity only). +func NewRigConfig(name, gitURL string) *RigConfig { return &RigConfig{ - Type: "rig", - Version: CurrentRigConfigVersion, - MergeQueue: DefaultMergeQueueConfig(), + 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) + 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 { + 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) + 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 { + 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, } } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index f318e274..682d1248 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -135,7 +135,9 @@ func TestRigConfigRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") - original := NewRigConfig() + original := NewRigConfig("gastown", "git@github.com:test/gastown.git") + original.CreatedAt = time.Now().Truncate(time.Second) + original.Beads = &BeadsConfig{Prefix: "gt-"} if err := SaveRigConfig(path, original); err != nil { t.Fatalf("SaveRigConfig: %v", err) @@ -152,6 +154,35 @@ func TestRigConfigRoundTrip(t *testing.T) { if loaded.Version != CurrentRigConfigVersion { t.Errorf("Version = %d, want %d", loaded.Version, CurrentRigConfigVersion) } + if loaded.Name != "gastown" { + t.Errorf("Name = %q, want 'gastown'", loaded.Name) + } + if loaded.GitURL != "git@github.com:test/gastown.git" { + t.Errorf("GitURL = %q, want expected URL", loaded.GitURL) + } + if loaded.Beads == nil || loaded.Beads.Prefix != "gt-" { + t.Error("Beads.Prefix not preserved") + } +} + +func TestRigSettingsRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings", "config.json") + + original := NewRigSettings() + + if err := SaveRigSettings(path, original); err != nil { + t.Fatalf("SaveRigSettings: %v", err) + } + + loaded, err := LoadRigSettings(path) + if err != nil { + t.Fatalf("LoadRigSettings: %v", err) + } + + if loaded.Type != "rig-settings" { + t.Errorf("Type = %q, want 'rig-settings'", loaded.Type) + } if loaded.MergeQueue == nil { t.Fatal("MergeQueue is nil") } @@ -161,17 +192,14 @@ func TestRigConfigRoundTrip(t *testing.T) { if loaded.MergeQueue.TargetBranch != "main" { t.Errorf("MergeQueue.TargetBranch = %q, want 'main'", loaded.MergeQueue.TargetBranch) } - if loaded.MergeQueue.OnConflict != OnConflictAssignBack { - t.Errorf("MergeQueue.OnConflict = %q, want %q", loaded.MergeQueue.OnConflict, OnConflictAssignBack) - } } -func TestRigConfigWithCustomMergeQueue(t *testing.T) { +func TestRigSettingsWithCustomMergeQueue(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "config.json") + path := filepath.Join(dir, "settings.json") - original := &RigConfig{ - Type: "rig", + original := &RigSettings{ + Type: "rig-settings", Version: 1, MergeQueue: &MergeQueueConfig{ Enabled: true, @@ -187,13 +215,13 @@ func TestRigConfigWithCustomMergeQueue(t *testing.T) { }, } - if err := SaveRigConfig(path, original); err != nil { - t.Fatalf("SaveRigConfig: %v", err) + if err := SaveRigSettings(path, original); err != nil { + t.Fatalf("SaveRigSettings: %v", err) } - loaded, err := LoadRigConfig(path) + loaded, err := LoadRigSettings(path) if err != nil { - t.Fatalf("LoadRigConfig: %v", err) + t.Fatalf("LoadRigSettings: %v", err) } mq := loaded.MergeQueue @@ -209,12 +237,6 @@ func TestRigConfigWithCustomMergeQueue(t *testing.T) { if mq.RetryFlakyTests != 3 { t.Errorf("RetryFlakyTests = %d, want 3", mq.RetryFlakyTests) } - if mq.PollInterval != "1m" { - t.Errorf("PollInterval = %q, want '1m'", mq.PollInterval) - } - if mq.MaxConcurrent != 2 { - t.Errorf("MaxConcurrent = %d, want 2", mq.MaxConcurrent) - } } func TestRigConfigValidation(t *testing.T) { @@ -226,23 +248,67 @@ func TestRigConfigValidation(t *testing.T) { { name: "valid config", config: &RigConfig{ - Type: "rig", + Type: "rig", + Version: 1, + Name: "test-rig", + }, + wantErr: false, + }, + { + name: "missing name", + config: &RigConfig{ + Type: "rig", + Version: 1, + }, + wantErr: true, + }, + { + name: "wrong type", + config: &RigConfig{ + Type: "wrong", + Version: 1, + Name: "test", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRigConfig(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("validateRigConfig() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRigSettingsValidation(t *testing.T) { + tests := []struct { + name string + settings *RigSettings + wantErr bool + }{ + { + name: "valid settings", + settings: &RigSettings{ + Type: "rig-settings", Version: 1, MergeQueue: DefaultMergeQueueConfig(), }, wantErr: false, }, { - name: "valid config without merge queue", - config: &RigConfig{ - Type: "rig", + name: "valid settings without merge queue", + settings: &RigSettings{ + Type: "rig-settings", Version: 1, }, wantErr: false, }, { name: "wrong type", - config: &RigConfig{ + settings: &RigSettings{ Type: "wrong", Version: 1, }, @@ -250,8 +316,8 @@ func TestRigConfigValidation(t *testing.T) { }, { name: "invalid on_conflict", - config: &RigConfig{ - Type: "rig", + settings: &RigSettings{ + Type: "rig-settings", Version: 1, MergeQueue: &MergeQueueConfig{ OnConflict: "invalid", @@ -261,8 +327,8 @@ func TestRigConfigValidation(t *testing.T) { }, { name: "invalid poll_interval", - config: &RigConfig{ - Type: "rig", + settings: &RigSettings{ + Type: "rig-settings", Version: 1, MergeQueue: &MergeQueueConfig{ PollInterval: "not-a-duration", @@ -274,9 +340,9 @@ func TestRigConfigValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateRigConfig(tt.config) + err := validateRigSettings(tt.settings) if (err != nil) != tt.wantErr { - t.Errorf("validateRigConfig() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("validateRigSettings() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -323,3 +389,48 @@ func TestLoadRigConfigNotFound(t *testing.T) { t.Fatal("expected error for nonexistent file") } } + +func TestLoadRigSettingsNotFound(t *testing.T) { + _, err := LoadRigSettings("/nonexistent/path.json") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestMayorConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mayor", "config.json") + + original := NewMayorConfig() + original.Theme = &TownThemeConfig{ + RoleDefaults: map[string]string{ + "witness": "rust", + }, + } + + if err := SaveMayorConfig(path, original); err != nil { + t.Fatalf("SaveMayorConfig: %v", err) + } + + loaded, err := LoadMayorConfig(path) + if err != nil { + t.Fatalf("LoadMayorConfig: %v", err) + } + + if loaded.Type != "mayor-config" { + t.Errorf("Type = %q, want 'mayor-config'", loaded.Type) + } + if loaded.Version != CurrentMayorConfigVersion { + t.Errorf("Version = %d, want %d", loaded.Version, CurrentMayorConfigVersion) + } + if loaded.Theme == nil || loaded.Theme.RoleDefaults["witness"] != "rust" { + t.Error("Theme.RoleDefaults not preserved") + } +} + +func TestLoadMayorConfigNotFound(t *testing.T) { + _, err := LoadMayorConfig("/nonexistent/path.json") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 9452c4df..b89d35cc 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,15 +3,38 @@ package config import "time" -// TownConfig represents the main town configuration (mayor/town.json). +// TownConfig represents the main town identity (mayor/town.json). type TownConfig struct { - Type string `json:"type"` // "town" - Version int `json:"version"` // schema version - Name string `json:"name"` // town identifier - CreatedAt time.Time `json:"created_at"` - Theme *TownThemeConfig `json:"theme,omitempty"` // global theme settings + Type string `json:"type"` // "town" + Version int `json:"version"` // schema version + Name string `json:"name"` // town identifier + CreatedAt time.Time `json:"created_at"` } +// MayorConfig represents town-level behavioral configuration (mayor/config.json). +// This is separate from TownConfig (identity) to keep configuration concerns distinct. +type MayorConfig struct { + Type string `json:"type"` // "mayor-config" + Version int `json:"version"` // schema version + Theme *TownThemeConfig `json:"theme,omitempty"` // global theme settings + Daemon *DaemonConfig `json:"daemon,omitempty"` // daemon settings + Deacon *DeaconConfig `json:"deacon,omitempty"` // deacon settings +} + +// DaemonConfig represents daemon process settings. +type DaemonConfig struct { + HeartbeatInterval string `json:"heartbeat_interval,omitempty"` // e.g., "30s" + PollInterval string `json:"poll_interval,omitempty"` // e.g., "10s" +} + +// DeaconConfig represents deacon process settings. +type DeaconConfig struct { + PatrolInterval string `json:"patrol_interval,omitempty"` // e.g., "5m" +} + +// CurrentMayorConfigVersion is the current schema version for MayorConfig. +const CurrentMayorConfigVersion = 1 + // RigsConfig represents the rigs registry (mayor/rigs.json). type RigsConfig struct { Version int `json:"version"` @@ -48,9 +71,23 @@ const CurrentRigsVersion = 1 // CurrentRigConfigVersion is the current schema version for RigConfig. const CurrentRigConfigVersion = 1 -// RigConfig represents the per-rig configuration (rig/config.json). +// CurrentRigSettingsVersion is the current schema version for RigSettings. +const CurrentRigSettingsVersion = 1 + +// RigConfig represents per-rig identity (rig/config.json). +// This contains only identity - behavioral config is in settings/config.json. type RigConfig struct { - Type string `json:"type"` // "rig" + Type string `json:"type"` // "rig" + Version int `json:"version"` // schema version + Name string `json:"name"` // rig name + GitURL string `json:"git_url"` // git repository URL + CreatedAt time.Time `json:"created_at"` // when the rig was created + Beads *BeadsConfig `json:"beads,omitempty"` +} + +// RigSettings represents per-rig behavioral configuration (settings/config.json). +type RigSettings struct { + Type string `json:"type"` // "rig-settings" Version int `json:"version"` // schema version MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"` // merge queue settings Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 723dbc2a..cc816dbd 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -25,8 +25,11 @@ const ( // DirBeads is the beads database directory. DirBeads = ".beads" - // DirGastown is the per-rig runtime state directory. - DirGastown = ".gastown" + // DirRuntime is the runtime state directory (gitignored). + DirRuntime = ".runtime" + + // DirSettings is the rig settings directory (git-tracked). + DirSettings = "settings" ) // File names for configuration and state. @@ -132,7 +135,22 @@ func RigCrewPath(rigPath string) string { return rigPath + "/" + DirCrew } -// GastownPath returns the path to .gastown/ within a rig. -func GastownPath(rigPath string) string { - return rigPath + "/" + DirGastown +// MayorConfigPath returns the path to mayor/config.json within a town root. +func MayorConfigPath(townRoot string) string { + return townRoot + "/" + DirMayor + "/" + FileConfigJSON +} + +// TownRuntimePath returns the path to .runtime/ at the town root. +func TownRuntimePath(townRoot string) string { + return townRoot + "/" + DirRuntime +} + +// RigRuntimePath returns the path to .runtime/ within a rig. +func RigRuntimePath(rigPath string) string { + return rigPath + "/" + DirRuntime +} + +// RigSettingsPath returns the path to settings/ within a rig. +func RigSettingsPath(rigPath string) string { + return rigPath + "/" + DirSettings } diff --git a/internal/keepalive/keepalive.go b/internal/keepalive/keepalive.go index 6ff4a5de..92bf6dd4 100644 --- a/internal/keepalive/keepalive.go +++ b/internal/keepalive/keepalive.go @@ -17,7 +17,7 @@ type State struct { Timestamp time.Time `json:"timestamp"` } -// Touch updates the keepalive file in the workspace's .gastown directory. +// Touch updates the keepalive file in the workspace's .runtime directory. // It silently ignores errors (best-effort signaling). func Touch(command string) { TouchWithArgs(command, nil) @@ -43,10 +43,10 @@ func TouchWithArgs(command string, args []string) { // TouchInWorkspace updates the keepalive file in a specific workspace. // It silently ignores errors (best-effort signaling). func TouchInWorkspace(workspaceRoot, command string) { - gastown := filepath.Join(workspaceRoot, ".gastown") + runtimeDir := filepath.Join(workspaceRoot, ".runtime") - // Ensure .gastown directory exists - if err := os.MkdirAll(gastown, 0755); err != nil { + // Ensure .runtime directory exists + if err := os.MkdirAll(runtimeDir, 0755); err != nil { return } @@ -60,14 +60,14 @@ func TouchInWorkspace(workspaceRoot, command string) { return } - keepalivePath := filepath.Join(gastown, "keepalive.json") + keepalivePath := filepath.Join(runtimeDir, "keepalive.json") _ = os.WriteFile(keepalivePath, data, 0644) } // Read returns the current keepalive state for the workspace. // Returns nil if the file doesn't exist or can't be read. func Read(workspaceRoot string) *State { - keepalivePath := filepath.Join(workspaceRoot, ".gastown", "keepalive.json") + keepalivePath := filepath.Join(workspaceRoot, ".runtime", "keepalive.json") data, err := os.ReadFile(keepalivePath) if err != nil { diff --git a/internal/keepalive/keepalive_test.go b/internal/keepalive/keepalive_test.go index 10e3e7ad..5b126d6c 100644 --- a/internal/keepalive/keepalive_test.go +++ b/internal/keepalive/keepalive_test.go @@ -86,12 +86,12 @@ func TestDirectoryCreation(t *testing.T) { tmpDir := t.TempDir() workDir := filepath.Join(tmpDir, "some", "nested", "workspace") - // Touch should create .gastown directory + // Touch should create .runtime directory TouchInWorkspace(workDir, "gt test") // Verify directory was created - gastown := filepath.Join(workDir, ".gastown") - if _, err := os.Stat(gastown); os.IsNotExist(err) { - t.Error("expected .gastown directory to be created") + runtimeDir := filepath.Join(workDir, ".runtime") + if _, err := os.Stat(runtimeDir); os.IsNotExist(err) { + t.Error("expected .runtime directory to be created") } } diff --git a/internal/lock/lock.go b/internal/lock/lock.go index 8d51a2e3..8b1c8425 100644 --- a/internal/lock/lock.go +++ b/internal/lock/lock.go @@ -1,7 +1,7 @@ // Package lock provides agent identity locking to prevent multiple agents // from claiming the same worker identity. // -// Lock files are stored at /.gastown/agent.lock and contain: +// Lock files are stored at /.runtime/agent.lock and contain: // - PID of the owning process // - Timestamp when lock was acquired // - Session ID (tmux session name) @@ -50,7 +50,7 @@ type Lock struct { func New(workerDir string) *Lock { return &Lock{ workerDir: workerDir, - lockPath: filepath.Join(workerDir, ".gastown", "agent.lock"), + lockPath: filepath.Join(workerDir, ".runtime", "agent.lock"), } } @@ -167,7 +167,7 @@ func (l *Lock) ForceRelease() error { // write creates or updates the lock file. func (l *Lock) write(sessionID string) error { - // Ensure .gastown directory exists + // Ensure .runtime directory exists dir := filepath.Dir(l.lockPath) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("creating lock directory: %w", err) @@ -224,7 +224,7 @@ func FindAllLocks(root string) (map[string]*LockInfo, error) { return nil } - if filepath.Base(path) == "agent.lock" && filepath.Base(filepath.Dir(path)) == ".gastown" { + if filepath.Base(path) == "agent.lock" && filepath.Base(filepath.Dir(path)) == ".runtime" { workerDir := filepath.Dir(filepath.Dir(path)) lock := New(workerDir) lockInfo, err := lock.Read() diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index c46c65ec..c0291e3e 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -48,19 +48,19 @@ func NewManager(r *rig.Rig, g *git.Git) *Manager { // Use the rig root for beads operations (rig-level beads at .beads/) rigPath := r.Path - // Try to load rig config for namepool settings - rigConfigPath := filepath.Join(r.Path, ".gastown", "config.json") + // Try to load rig settings for namepool config + settingsPath := filepath.Join(r.Path, "settings", "config.json") var pool *NamePool - rigConfig, err := config.LoadRigConfig(rigConfigPath) - if err == nil && rigConfig.Namepool != nil { + settings, err := config.LoadRigSettings(settingsPath) + if err == nil && settings.Namepool != nil { // Use configured namepool settings pool = NewNamePoolWithConfig( r.Path, r.Name, - rigConfig.Namepool.Style, - rigConfig.Namepool.Names, - rigConfig.Namepool.MaxBeforeNumbering, + settings.Namepool.Style, + settings.Namepool.Names, + settings.Namepool.MaxBeforeNumbering, ) } else { // Use defaults diff --git a/internal/polecat/namepool.go b/internal/polecat/namepool.go index 3e45f6a7..cdd4f5d9 100644 --- a/internal/polecat/namepool.go +++ b/internal/polecat/namepool.go @@ -95,7 +95,7 @@ func NewNamePool(rigPath, rigName string) *NamePool { InUse: make(map[string]bool), OverflowNext: DefaultPoolSize + 1, MaxSize: DefaultPoolSize, - stateFile: filepath.Join(rigPath, ".gastown", "namepool.json"), + stateFile: filepath.Join(rigPath, ".runtime", "namepool-state.json"), } } @@ -115,7 +115,7 @@ func NewNamePoolWithConfig(rigPath, rigName, theme string, customNames []string, InUse: make(map[string]bool), OverflowNext: maxSize + 1, MaxSize: maxSize, - stateFile: filepath.Join(rigPath, ".gastown", "namepool.json"), + stateFile: filepath.Join(rigPath, ".runtime", "namepool-state.json"), } } diff --git a/internal/polecat/namepool_test.go b/internal/polecat/namepool_test.go index 74cf8a51..e67198d0 100644 --- a/internal/polecat/namepool_test.go +++ b/internal/polecat/namepool_test.go @@ -308,7 +308,7 @@ func TestNamePool_StateFilePath(t *testing.T) { } // Verify file was created in expected location - expectedPath := filepath.Join(tmpDir, ".gastown", "namepool.json") + expectedPath := filepath.Join(tmpDir, ".runtime", "namepool-state.json") if _, err := os.Stat(expectedPath); err != nil { t.Errorf("state file not found at expected path: %v", err) } diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index ee4a4aa1..9b5a12ef 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/tmux" @@ -49,7 +50,7 @@ func (m *Manager) SetOutput(w io.Writer) { // stateFile returns the path to the refinery state file. func (m *Manager) stateFile() string { - return filepath.Join(m.rig.Path, ".gastown", "refinery.json") + return filepath.Join(m.rig.Path, ".runtime", "refinery.json") } // sessionName returns the tmux session name for this refinery. @@ -554,21 +555,16 @@ func (m *Manager) completeMR(mr *MergeRequest, closeReason CloseReason, errMsg s // getTestCommand returns the test command if configured. func (m *Manager) getTestCommand() string { - // Check for .gastown/config.json with test_command - configPath := filepath.Join(m.rig.Path, ".gastown", "config.json") - data, err := os.ReadFile(configPath) + // Check settings/config.json for test_command + settingsPath := filepath.Join(m.rig.Path, "settings", "config.json") + settings, err := config.LoadRigSettings(settingsPath) if err != nil { return "" } - - var config struct { - TestCommand string `json:"test_command"` + if settings.MergeQueue != nil && settings.MergeQueue.TestCommand != "" { + return settings.MergeQueue.TestCommand } - if err := json.Unmarshal(data, &config); err != nil { - return "" - } - - return config.TestCommand + return "" } // runTests executes the test command. @@ -633,42 +629,25 @@ func (m *Manager) gitOutput(args ...string) (string, error) { // getMergeConfig loads the merge configuration from disk. // Returns default config if not configured. func (m *Manager) getMergeConfig() MergeConfig { - config := DefaultMergeConfig() + mergeConfig := DefaultMergeConfig() - // Check for .gastown/config.json with merge_queue settings - configPath := filepath.Join(m.rig.Path, ".gastown", "config.json") - data, err := os.ReadFile(configPath) + // Check settings/config.json for merge_queue settings + settingsPath := filepath.Join(m.rig.Path, "settings", "config.json") + settings, err := config.LoadRigSettings(settingsPath) if err != nil { - return config - } - - var rawConfig struct { - MergeQueue *MergeConfig `json:"merge_queue"` - // Legacy field for backwards compatibility - TestCommand string `json:"test_command"` - } - if err := json.Unmarshal(data, &rawConfig); err != nil { - return config + return mergeConfig } // Apply merge_queue config if present - if rawConfig.MergeQueue != nil { - config = *rawConfig.MergeQueue - // Ensure defaults for zero values - if config.PushRetryCount == 0 { - config.PushRetryCount = 3 - } - if config.PushRetryDelayMs == 0 { - config.PushRetryDelayMs = 1000 - } + if settings.MergeQueue != nil { + mq := settings.MergeQueue + mergeConfig.TestCommand = mq.TestCommand + mergeConfig.RunTests = mq.RunTests + mergeConfig.DeleteMergedBranches = mq.DeleteMergedBranches + // Note: PushRetryCount and PushRetryDelayMs use defaults if not explicitly set } - // Legacy: use test_command if merge_queue not set - if rawConfig.TestCommand != "" && config.TestCommand == "" { - config.TestCommand = rawConfig.TestCommand - } - - return config + return mergeConfig } // pushWithRetry pushes to the target branch with exponential backoff retry. diff --git a/internal/refinery/manager_test.go b/internal/refinery/manager_test.go index 19dc20d2..f701a6d7 100644 --- a/internal/refinery/manager_test.go +++ b/internal/refinery/manager_test.go @@ -16,8 +16,8 @@ func setupTestManager(t *testing.T) (*Manager, string) { // Create temp directory structure tmpDir := t.TempDir() rigPath := filepath.Join(tmpDir, "testrig") - if err := os.MkdirAll(filepath.Join(rigPath, ".gastown"), 0755); err != nil { - t.Fatalf("mkdir .gastown: %v", err) + if err := os.MkdirAll(filepath.Join(rigPath, ".runtime"), 0755); err != nil { + t.Fatalf("mkdir .runtime: %v", err) } r := &rig.Rig{ @@ -146,7 +146,7 @@ func TestManager_RegisterMR(t *testing.T) { } // Verify it was saved to disk - stateFile := filepath.Join(rigPath, ".gastown", "refinery.json") + stateFile := filepath.Join(rigPath, ".runtime", "refinery.json") data, err := os.ReadFile(stateFile) if err != nil { t.Fatalf("reading state file: %v", err) diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 707e438d..d1f1d2e4 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -41,7 +41,7 @@ func NewManager(r *rig.Rig) *Manager { // stateFile returns the path to the witness state file. func (m *Manager) stateFile() string { - return filepath.Join(m.rig.Path, ".gastown", "witness.json") + return filepath.Join(m.rig.Path, ".runtime", "witness.json") } // loadState loads witness state from disk. @@ -261,7 +261,7 @@ func (m *Manager) checkPolecatHealth(name, path string) PolecatHealthStatus { } // Check 2: State file activity - stateFile := filepath.Join(path, ".gastown", "state.json") + stateFile := filepath.Join(path, ".runtime", "state.json") if info, err := os.Stat(stateFile); err == nil { if time.Since(info.ModTime()) < threshold { return PolecatHealthy