Implement three-tier config architecture (gt-k1lr tasks 1-5)

**Architecture changes:**
- Renamed `.gastown/` → `.runtime/` for runtime state (gitignored)
- Added `settings/` directory for rig behavioral config (git-tracked)
- Added `mayor/config.json` for town-level config (MayorConfig type)
- Separated RigConfig (identity) from RigSettings (behavioral)

**File location changes:**
- Town runtime: `~/.gastown/*` → `~/.runtime/*`
- Rig runtime: `<rig>/.gastown/*` → `<rig>/.runtime/*`
- Rig config: `<rig>/.gastown/config.json` → `<rig>/settings/config.json`
- Namepool state: `namepool.json` → `namepool-state.json`

**New types:**
- MayorConfig: town-level behavioral config
- RigSettings: rig behavioral config (merge_queue, theme, namepool)
- RigConfig now identity-only (name, git_url, beads, created_at)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-22 01:22:27 -08:00
parent e481d5e8f3
commit 4917f35470
20 changed files with 449 additions and 201 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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),

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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 <worker>/.gastown/agent.lock and contain:
// Lock files are stored at <worker>/.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()

View File

@@ -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

View File

@@ -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"),
}
}

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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