Files
gastown/internal/config/roles.go
gastown/crew/max 544cacf36d feat(roles): add config-based role definition system (Phase 1)
Replace role beads with embedded TOML config files for role definitions.
This is Phase 1 of gt-y1uvb - adds the config infrastructure without
yet switching the daemon to use it.

New files:
- internal/config/roles.go: RoleDefinition types, LoadRoleDefinition()
  with layered override resolution (builtin → town → rig)
- internal/config/roles/*.toml: 7 embedded role definitions
- internal/config/roles_test.go: unit tests

New command:
- gt role def <role>: displays effective role configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 12:57:46 -08:00

299 lines
9.1 KiB
Go

// Package config provides role configuration for Gas Town agents.
package config
import (
"embed"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
//go:embed roles/*.toml
var defaultRolesFS embed.FS
// RoleDefinition contains all configuration for a role type.
// This replaces the role bead system with config files.
type RoleDefinition struct {
// Role is the role identifier (mayor, deacon, witness, refinery, polecat, crew, dog).
Role string `toml:"role"`
// Scope is "town" or "rig" - determines where the agent runs.
Scope string `toml:"scope"`
// Session contains tmux session configuration.
Session RoleSessionConfig `toml:"session"`
// Env contains environment variables to set in the session.
Env map[string]string `toml:"env,omitempty"`
// Health contains health check configuration.
Health RoleHealthConfig `toml:"health"`
// Nudge is the initial prompt sent when starting the agent.
Nudge string `toml:"nudge,omitempty"`
// PromptTemplate is the name of the role's prompt template file.
PromptTemplate string `toml:"prompt_template,omitempty"`
}
// RoleSessionConfig contains session-related configuration.
type RoleSessionConfig struct {
// Pattern is the tmux session name pattern.
// Supports placeholders: {rig}, {name}, {role}
// Examples: "hq-mayor", "gt-{rig}-witness", "gt-{rig}-{name}"
Pattern string `toml:"pattern"`
// WorkDir is the working directory pattern.
// Supports placeholders: {town}, {rig}, {name}, {role}
// Examples: "{town}", "{town}/{rig}/witness"
WorkDir string `toml:"work_dir"`
// NeedsPreSync indicates if workspace needs git sync before starting.
NeedsPreSync bool `toml:"needs_pre_sync"`
// StartCommand is the command to run after creating the session.
// Default: "exec claude --dangerously-skip-permissions"
StartCommand string `toml:"start_command,omitempty"`
}
// RoleHealthConfig contains health check thresholds.
type RoleHealthConfig struct {
// PingTimeout is how long to wait for a health check response.
PingTimeout Duration `toml:"ping_timeout"`
// ConsecutiveFailures is how many failed health checks before force-kill.
ConsecutiveFailures int `toml:"consecutive_failures"`
// KillCooldown is the minimum time between force-kills.
KillCooldown Duration `toml:"kill_cooldown"`
// StuckThreshold is how long a wisp can be in_progress before considered stuck.
StuckThreshold Duration `toml:"stuck_threshold"`
}
// Duration is a wrapper for time.Duration that supports TOML marshaling.
type Duration struct {
time.Duration
}
// UnmarshalText implements encoding.TextUnmarshaler for Duration.
func (d *Duration) UnmarshalText(text []byte) error {
parsed, err := time.ParseDuration(string(text))
if err != nil {
return fmt.Errorf("invalid duration %q: %w", string(text), err)
}
d.Duration = parsed
return nil
}
// MarshalText implements encoding.TextMarshaler for Duration.
func (d Duration) MarshalText() ([]byte, error) {
return []byte(d.Duration.String()), nil
}
// String returns the duration as a string.
func (d Duration) String() string {
return d.Duration.String()
}
// AllRoles returns the list of all known role names.
func AllRoles() []string {
return []string{"mayor", "deacon", "dog", "witness", "refinery", "polecat", "crew"}
}
// TownRoles returns roles that operate at town scope.
func TownRoles() []string {
return []string{"mayor", "deacon", "dog"}
}
// RigRoles returns roles that operate at rig scope.
func RigRoles() []string {
return []string{"witness", "refinery", "polecat", "crew"}
}
// isValidRoleName checks if the given name is a known role.
func isValidRoleName(name string) bool {
for _, r := range AllRoles() {
if r == name {
return true
}
}
return false
}
// LoadRoleDefinition loads role configuration with override resolution.
// Resolution order (later overrides earlier):
// 1. Built-in defaults (embedded in binary)
// 2. Town-level overrides (<town>/roles/<role>.toml)
// 3. Rig-level overrides (<rig>/roles/<role>.toml)
//
// Each layer merges with (not replaces) the previous. Users only specify
// fields they want to change.
func LoadRoleDefinition(townRoot, rigPath, roleName string) (*RoleDefinition, error) {
// Validate role name
if !isValidRoleName(roleName) {
return nil, fmt.Errorf("unknown role %q - valid roles: %v", roleName, AllRoles())
}
// 1. Load built-in defaults
def, err := loadBuiltinRoleDefinition(roleName)
if err != nil {
return nil, fmt.Errorf("loading built-in role %s: %w", roleName, err)
}
// 2. Apply town-level overrides if present
townOverridePath := filepath.Join(townRoot, "roles", roleName+".toml")
if override, err := loadRoleOverride(townOverridePath); err == nil {
mergeRoleDefinition(def, override)
}
// 3. Apply rig-level overrides if present (only for rig-scoped roles)
if rigPath != "" {
rigOverridePath := filepath.Join(rigPath, "roles", roleName+".toml")
if override, err := loadRoleOverride(rigOverridePath); err == nil {
mergeRoleDefinition(def, override)
}
}
return def, nil
}
// loadBuiltinRoleDefinition loads a role definition from embedded defaults.
func loadBuiltinRoleDefinition(roleName string) (*RoleDefinition, error) {
data, err := defaultRolesFS.ReadFile("roles/" + roleName + ".toml")
if err != nil {
return nil, fmt.Errorf("role %s not found in defaults: %w", roleName, err)
}
var def RoleDefinition
if err := toml.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parsing role %s: %w", roleName, err)
}
return &def, nil
}
// loadRoleOverride loads a role override from a file path.
// Returns nil, nil if file doesn't exist.
func loadRoleOverride(path string) (*RoleDefinition, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, err // Signal no override exists
}
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var def RoleDefinition
if err := toml.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return &def, nil
}
// mergeRoleDefinition merges override into base.
// Only non-zero values in override are applied.
func mergeRoleDefinition(base, override *RoleDefinition) {
if override == nil {
return
}
// Role and Scope are immutable
// (can't change a witness to a mayor via override)
// Session config
if override.Session.Pattern != "" {
base.Session.Pattern = override.Session.Pattern
}
if override.Session.WorkDir != "" {
base.Session.WorkDir = override.Session.WorkDir
}
// NeedsPreSync can only be enabled via override, not disabled.
// This is intentional: if a role's builtin requires pre-sync (e.g., refinery),
// disabling it would break the role's assumptions about workspace state.
if override.Session.NeedsPreSync {
base.Session.NeedsPreSync = true
}
if override.Session.StartCommand != "" {
base.Session.StartCommand = override.Session.StartCommand
}
// Env vars (merge, don't replace)
if override.Env != nil {
if base.Env == nil {
base.Env = make(map[string]string)
}
for k, v := range override.Env {
base.Env[k] = v
}
}
// Health config
if override.Health.PingTimeout.Duration != 0 {
base.Health.PingTimeout = override.Health.PingTimeout
}
if override.Health.ConsecutiveFailures != 0 {
base.Health.ConsecutiveFailures = override.Health.ConsecutiveFailures
}
if override.Health.KillCooldown.Duration != 0 {
base.Health.KillCooldown = override.Health.KillCooldown
}
if override.Health.StuckThreshold.Duration != 0 {
base.Health.StuckThreshold = override.Health.StuckThreshold
}
// Prompts
if override.Nudge != "" {
base.Nudge = override.Nudge
}
if override.PromptTemplate != "" {
base.PromptTemplate = override.PromptTemplate
}
}
// ExpandPattern expands placeholders in a pattern string.
// Supported placeholders: {town}, {rig}, {name}, {role}
func ExpandPattern(pattern, townRoot, rig, name, role string) string {
result := pattern
result = strings.ReplaceAll(result, "{town}", townRoot)
result = strings.ReplaceAll(result, "{rig}", rig)
result = strings.ReplaceAll(result, "{name}", name)
result = strings.ReplaceAll(result, "{role}", role)
return result
}
// ToLegacyRoleConfig converts a RoleDefinition to the legacy RoleConfig format
// for backward compatibility with existing daemon code.
func (rd *RoleDefinition) ToLegacyRoleConfig() *LegacyRoleConfig {
return &LegacyRoleConfig{
SessionPattern: rd.Session.Pattern,
WorkDirPattern: rd.Session.WorkDir,
NeedsPreSync: rd.Session.NeedsPreSync,
StartCommand: rd.Session.StartCommand,
EnvVars: rd.Env,
PingTimeout: rd.Health.PingTimeout.String(),
ConsecutiveFailures: rd.Health.ConsecutiveFailures,
KillCooldown: rd.Health.KillCooldown.String(),
StuckThreshold: rd.Health.StuckThreshold.String(),
}
}
// LegacyRoleConfig matches the old beads.RoleConfig struct for compatibility.
// This allows gradual migration without breaking existing code.
type LegacyRoleConfig struct {
SessionPattern string
WorkDirPattern string
NeedsPreSync bool
StartCommand string
EnvVars map[string]string
PingTimeout string
ConsecutiveFailures int
KillCooldown string
StuckThreshold string
}