diff --git a/internal/cmd/sling_schedule.go b/internal/cmd/sling_schedule.go index 318a606e28..52f442a31e 100644 --- a/internal/cmd/sling_schedule.go +++ b/internal/cmd/sling_schedule.go @@ -11,6 +11,7 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/events" + "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/scheduler/capacity" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" @@ -252,8 +253,16 @@ func resolveRigForBead(townRoot, beadID string) string { } // resolveFormula determines the formula name from user flags and rig settings. -// It checks the rig's workflow.default_formula setting before falling back to -// the hardcoded "mol-polecat-work" default. +// Resolution order: +// 1. Explicit --formula flag +// 2. Rig property layers (wisp → bead → system default "mol-polecat-work") +// 3. Rig settings file (workflow.default_formula in settings/config.json) +// 4. Hardcoded fallback "mol-polecat-work" +// +// The property layers are the primary mechanism, supporting: +// +// gt rig config set default_formula mol-evolve # wisp layer +// gt rig config set default_formula mol-evolve --global # bead layer func resolveFormula(explicit string, hookRawBead bool, townRoot, rigName string) string { if hookRawBead { return "" @@ -261,7 +270,17 @@ func resolveFormula(explicit string, hookRawBead bool, townRoot, rigName string) if explicit != "" { return explicit } - // Check rig's default_formula setting (issue gt-boc). + // Check rig property layers: wisp → bead → system default (issue gt-y18). + if townRoot != "" && rigName != "" { + r := &rig.Rig{ + Name: rigName, + Path: filepath.Join(townRoot, rigName), + } + if df := r.GetStringConfig("default_formula"); df != "" { + return df + } + } + // Fallback: check rig settings file (legacy path, issue gt-boc). if townRoot != "" && rigName != "" { rigPath := filepath.Join(townRoot, rigName) if df := config.GetDefaultFormula(rigPath); df != "" { diff --git a/internal/cmd/sling_schedule_test.go b/internal/cmd/sling_schedule_test.go index f7f296e554..43bf3e3f89 100644 --- a/internal/cmd/sling_schedule_test.go +++ b/internal/cmd/sling_schedule_test.go @@ -2,7 +2,10 @@ package cmd import ( "os" + "path/filepath" "testing" + + "github.com/steveyegge/gastown/internal/wisp" ) // TestAreScheduledFailClosed verifies that areScheduled fails closed when @@ -40,3 +43,78 @@ func TestAreScheduledEmptyInput(t *testing.T) { t.Errorf("areScheduled([]) should return empty map, got %d entries", len(result)) } } + +// TestResolveFormula verifies formula resolution precedence: +// explicit flag > wisp layer > bead layer > system default > settings file > hardcoded fallback. +func TestResolveFormula(t *testing.T) { + t.Parallel() + + t.Run("explicit flag wins", func(t *testing.T) { + t.Parallel() + got := resolveFormula("mol-evolve", false, "/tmp/nonexistent", "myrig") + if got != "mol-evolve" { + t.Errorf("got %q, want %q", got, "mol-evolve") + } + }) + + t.Run("hookRawBead returns empty", func(t *testing.T) { + t.Parallel() + got := resolveFormula("mol-evolve", true, "/tmp/nonexistent", "myrig") + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) + + t.Run("system default mol-polecat-work", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + rigName := "testrig" + _ = os.MkdirAll(filepath.Join(tmpDir, rigName), 0o755) + got := resolveFormula("", false, tmpDir, rigName) + if got != "mol-polecat-work" { + t.Errorf("got %q, want %q", got, "mol-polecat-work") + } + }) + + t.Run("wisp layer overrides system default", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + rigName := "testrig" + _ = os.MkdirAll(filepath.Join(tmpDir, rigName), 0o755) + + wispCfg := wisp.NewConfig(tmpDir, rigName) + if err := wispCfg.Set("default_formula", "mol-evolve"); err != nil { + t.Fatalf("wisp set: %v", err) + } + + got := resolveFormula("", false, tmpDir, rigName) + if got != "mol-evolve" { + t.Errorf("got %q, want %q", got, "mol-evolve") + } + }) + + t.Run("explicit flag overrides wisp layer", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + rigName := "testrig" + _ = os.MkdirAll(filepath.Join(tmpDir, rigName), 0o755) + + wispCfg := wisp.NewConfig(tmpDir, rigName) + if err := wispCfg.Set("default_formula", "mol-evolve"); err != nil { + t.Fatalf("wisp set: %v", err) + } + + got := resolveFormula("mol-custom", false, tmpDir, rigName) + if got != "mol-custom" { + t.Errorf("got %q, want %q", got, "mol-custom") + } + }) + + t.Run("empty rigName falls back to hardcoded default", func(t *testing.T) { + t.Parallel() + got := resolveFormula("", false, "/tmp/nonexistent", "") + if got != "mol-polecat-work" { + t.Errorf("got %q, want %q", got, "mol-polecat-work") + } + }) +} diff --git a/internal/rig/config.go b/internal/rig/config.go index 8ff7f2149a..0d680e3bc7 100644 --- a/internal/rig/config.go +++ b/internal/rig/config.go @@ -38,6 +38,7 @@ var SystemDefaults = map[string]interface{}{ "priority_adjustment": 0, "dnd": false, "polecat_branch_template": "", // Empty = use default behavior (polecat/{name}/...) + "default_formula": "mol-polecat-work", } // StackingKeys defines which keys use stacking semantics (values add up).