ZFC #4: Replace daemon identity parsing with agent self-registration
Implements role-based lifecycle configuration where agent types self-register via role beads instead of hardcoded identity string parsing in the daemon. Changes: - Add RoleConfig struct with lifecycle fields (session_pattern, work_dir_pattern, needs_pre_sync, start_command, env_vars) - Add ParseRoleConfig/FormatRoleConfig/ExpandRolePattern to beads package - Add role bead ID helpers (RoleBeadID, MayorRoleBeadID, etc.) - Refactor daemon to use single parseIdentity function as ONLY place where identity strings are parsed - Daemon now looks up role beads to get lifecycle config, with fallback to defaults when role bead is missing or has no config - Updated all role beads (mayor, deacon, witness, refinery, crew, polecat) with structured lifecycle configuration fields - Add comprehensive unit tests for RoleConfig parsing and expansion This makes the daemon ZFC-compliant by trusting what agents self-report in their role beads rather than encoding agent-specific knowledge in Go code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -888,3 +888,68 @@ func IsAgentSessionBead(beadID string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Role bead ID naming convention:
|
||||
// gt-<role>-role
|
||||
//
|
||||
// Examples:
|
||||
// - gt-mayor-role
|
||||
// - gt-deacon-role
|
||||
// - gt-witness-role
|
||||
// - gt-refinery-role
|
||||
// - gt-crew-role
|
||||
// - gt-polecat-role
|
||||
|
||||
// RoleBeadID returns the role bead ID for a given role type.
|
||||
// Role beads define lifecycle configuration for each agent type.
|
||||
func RoleBeadID(roleType string) string {
|
||||
return "gt-" + roleType + "-role"
|
||||
}
|
||||
|
||||
// MayorRoleBeadID returns the Mayor role bead ID.
|
||||
func MayorRoleBeadID() string {
|
||||
return RoleBeadID("mayor")
|
||||
}
|
||||
|
||||
// DeaconRoleBeadID returns the Deacon role bead ID.
|
||||
func DeaconRoleBeadID() string {
|
||||
return RoleBeadID("deacon")
|
||||
}
|
||||
|
||||
// WitnessRoleBeadID returns the Witness role bead ID.
|
||||
func WitnessRoleBeadID() string {
|
||||
return RoleBeadID("witness")
|
||||
}
|
||||
|
||||
// RefineryRoleBeadID returns the Refinery role bead ID.
|
||||
func RefineryRoleBeadID() string {
|
||||
return RoleBeadID("refinery")
|
||||
}
|
||||
|
||||
// CrewRoleBeadID returns the Crew role bead ID.
|
||||
func CrewRoleBeadID() string {
|
||||
return RoleBeadID("crew")
|
||||
}
|
||||
|
||||
// PolecatRoleBeadID returns the Polecat role bead ID.
|
||||
func PolecatRoleBeadID() string {
|
||||
return RoleBeadID("polecat")
|
||||
}
|
||||
|
||||
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
|
||||
// Returns nil, nil if the role bead doesn't exist or has no config.
|
||||
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
|
||||
issue, err := b.Show(roleBeadID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if issue.Type != "role" {
|
||||
return nil, fmt.Errorf("bead %s is not a role bead (type: %s)", roleBeadID, issue.Type)
|
||||
}
|
||||
|
||||
return ParseRoleConfig(issue.Description), nil
|
||||
}
|
||||
|
||||
@@ -1073,3 +1073,307 @@ func TestIsAgentSessionBead(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRoleConfig tests parsing role configuration from descriptions.
|
||||
func TestParseRoleConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
description string
|
||||
wantNil bool
|
||||
wantConfig *RoleConfig
|
||||
}{
|
||||
{
|
||||
name: "empty description",
|
||||
description: "",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "no role config fields",
|
||||
description: "This is just plain text\nwith no role config fields",
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "all fields",
|
||||
description: `session_pattern: gt-{rig}-{name}
|
||||
work_dir_pattern: {town}/{rig}/polecats/{name}
|
||||
needs_pre_sync: true
|
||||
start_command: exec claude --dangerously-skip-permissions
|
||||
env_var: GT_ROLE=polecat
|
||||
env_var: GT_RIG={rig}`,
|
||||
wantConfig: &RoleConfig{
|
||||
SessionPattern: "gt-{rig}-{name}",
|
||||
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
||||
NeedsPreSync: true,
|
||||
StartCommand: "exec claude --dangerously-skip-permissions",
|
||||
EnvVars: map[string]string{"GT_ROLE": "polecat", "GT_RIG": "{rig}"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "partial fields",
|
||||
description: `session_pattern: gt-mayor
|
||||
work_dir_pattern: {town}`,
|
||||
wantConfig: &RoleConfig{
|
||||
SessionPattern: "gt-mayor",
|
||||
WorkDirPattern: "{town}",
|
||||
EnvVars: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed with prose",
|
||||
description: `You are the Witness.
|
||||
|
||||
session_pattern: gt-{rig}-witness
|
||||
work_dir_pattern: {town}/{rig}
|
||||
needs_pre_sync: false
|
||||
|
||||
Your job is to monitor workers.`,
|
||||
wantConfig: &RoleConfig{
|
||||
SessionPattern: "gt-{rig}-witness",
|
||||
WorkDirPattern: "{town}/{rig}",
|
||||
NeedsPreSync: false,
|
||||
EnvVars: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alternate key formats (hyphen)",
|
||||
description: `session-pattern: gt-{rig}-{name}
|
||||
work-dir-pattern: {town}/{rig}/polecats/{name}
|
||||
needs-pre-sync: true`,
|
||||
wantConfig: &RoleConfig{
|
||||
SessionPattern: "gt-{rig}-{name}",
|
||||
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
||||
NeedsPreSync: true,
|
||||
EnvVars: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case insensitive keys",
|
||||
description: `SESSION_PATTERN: gt-mayor
|
||||
Work_Dir_Pattern: {town}`,
|
||||
wantConfig: &RoleConfig{
|
||||
SessionPattern: "gt-mayor",
|
||||
WorkDirPattern: "{town}",
|
||||
EnvVars: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ignores null values",
|
||||
description: `session_pattern: gt-{rig}-witness
|
||||
work_dir_pattern: null
|
||||
needs_pre_sync: false`,
|
||||
wantConfig: &RoleConfig{
|
||||
SessionPattern: "gt-{rig}-witness",
|
||||
EnvVars: map[string]string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := ParseRoleConfig(tt.description)
|
||||
|
||||
if tt.wantNil {
|
||||
if config != nil {
|
||||
t.Errorf("ParseRoleConfig() = %+v, want nil", config)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
t.Fatal("ParseRoleConfig() = nil, want non-nil")
|
||||
}
|
||||
|
||||
if config.SessionPattern != tt.wantConfig.SessionPattern {
|
||||
t.Errorf("SessionPattern = %q, want %q", config.SessionPattern, tt.wantConfig.SessionPattern)
|
||||
}
|
||||
if config.WorkDirPattern != tt.wantConfig.WorkDirPattern {
|
||||
t.Errorf("WorkDirPattern = %q, want %q", config.WorkDirPattern, tt.wantConfig.WorkDirPattern)
|
||||
}
|
||||
if config.NeedsPreSync != tt.wantConfig.NeedsPreSync {
|
||||
t.Errorf("NeedsPreSync = %v, want %v", config.NeedsPreSync, tt.wantConfig.NeedsPreSync)
|
||||
}
|
||||
if config.StartCommand != tt.wantConfig.StartCommand {
|
||||
t.Errorf("StartCommand = %q, want %q", config.StartCommand, tt.wantConfig.StartCommand)
|
||||
}
|
||||
if len(config.EnvVars) != len(tt.wantConfig.EnvVars) {
|
||||
t.Errorf("EnvVars len = %d, want %d", len(config.EnvVars), len(tt.wantConfig.EnvVars))
|
||||
}
|
||||
for k, v := range tt.wantConfig.EnvVars {
|
||||
if config.EnvVars[k] != v {
|
||||
t.Errorf("EnvVars[%q] = %q, want %q", k, config.EnvVars[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandRolePattern tests pattern expansion with placeholders.
|
||||
func TestExpandRolePattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
townRoot string
|
||||
rig string
|
||||
name string
|
||||
role string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
pattern: "gt-mayor",
|
||||
townRoot: "/Users/stevey/gt",
|
||||
want: "gt-mayor",
|
||||
},
|
||||
{
|
||||
pattern: "gt-{rig}-{role}",
|
||||
townRoot: "/Users/stevey/gt",
|
||||
rig: "gastown",
|
||||
role: "witness",
|
||||
want: "gt-gastown-witness",
|
||||
},
|
||||
{
|
||||
pattern: "gt-{rig}-{name}",
|
||||
townRoot: "/Users/stevey/gt",
|
||||
rig: "gastown",
|
||||
name: "toast",
|
||||
want: "gt-gastown-toast",
|
||||
},
|
||||
{
|
||||
pattern: "{town}/{rig}/polecats/{name}",
|
||||
townRoot: "/Users/stevey/gt",
|
||||
rig: "gastown",
|
||||
name: "toast",
|
||||
want: "/Users/stevey/gt/gastown/polecats/toast",
|
||||
},
|
||||
{
|
||||
pattern: "{town}/{rig}/refinery/rig",
|
||||
townRoot: "/Users/stevey/gt",
|
||||
rig: "gastown",
|
||||
want: "/Users/stevey/gt/gastown/refinery/rig",
|
||||
},
|
||||
{
|
||||
pattern: "export GT_ROLE={role} GT_RIG={rig} BD_ACTOR={rig}/polecats/{name}",
|
||||
townRoot: "/Users/stevey/gt",
|
||||
rig: "gastown",
|
||||
name: "toast",
|
||||
role: "polecat",
|
||||
want: "export GT_ROLE=polecat GT_RIG=gastown BD_ACTOR=gastown/polecats/toast",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pattern, func(t *testing.T) {
|
||||
got := ExpandRolePattern(tt.pattern, tt.townRoot, tt.rig, tt.name, tt.role)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExpandRolePattern() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatRoleConfig tests formatting role config to string.
|
||||
func TestFormatRoleConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *RoleConfig
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty config",
|
||||
config: &RoleConfig{EnvVars: map[string]string{}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "all fields",
|
||||
config: &RoleConfig{
|
||||
SessionPattern: "gt-{rig}-{name}",
|
||||
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
||||
NeedsPreSync: true,
|
||||
StartCommand: "exec claude",
|
||||
EnvVars: map[string]string{},
|
||||
},
|
||||
want: `session_pattern: gt-{rig}-{name}
|
||||
work_dir_pattern: {town}/{rig}/polecats/{name}
|
||||
needs_pre_sync: true
|
||||
start_command: exec claude`,
|
||||
},
|
||||
{
|
||||
name: "only session pattern",
|
||||
config: &RoleConfig{
|
||||
SessionPattern: "gt-mayor",
|
||||
EnvVars: map[string]string{},
|
||||
},
|
||||
want: "session_pattern: gt-mayor",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatRoleConfig(tt.config)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatRoleConfig() =\n%q\nwant\n%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleConfigRoundTrip tests that parse/format round-trips correctly.
|
||||
func TestRoleConfigRoundTrip(t *testing.T) {
|
||||
original := &RoleConfig{
|
||||
SessionPattern: "gt-{rig}-{name}",
|
||||
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
||||
NeedsPreSync: true,
|
||||
StartCommand: "exec claude --dangerously-skip-permissions",
|
||||
EnvVars: map[string]string{}, // Can't round-trip env vars due to order
|
||||
}
|
||||
|
||||
// Format to string
|
||||
formatted := FormatRoleConfig(original)
|
||||
|
||||
// Parse back
|
||||
parsed := ParseRoleConfig(formatted)
|
||||
|
||||
if parsed == nil {
|
||||
t.Fatal("round-trip parse returned nil")
|
||||
}
|
||||
|
||||
if parsed.SessionPattern != original.SessionPattern {
|
||||
t.Errorf("round-trip SessionPattern = %q, want %q", parsed.SessionPattern, original.SessionPattern)
|
||||
}
|
||||
if parsed.WorkDirPattern != original.WorkDirPattern {
|
||||
t.Errorf("round-trip WorkDirPattern = %q, want %q", parsed.WorkDirPattern, original.WorkDirPattern)
|
||||
}
|
||||
if parsed.NeedsPreSync != original.NeedsPreSync {
|
||||
t.Errorf("round-trip NeedsPreSync = %v, want %v", parsed.NeedsPreSync, original.NeedsPreSync)
|
||||
}
|
||||
if parsed.StartCommand != original.StartCommand {
|
||||
t.Errorf("round-trip StartCommand = %q, want %q", parsed.StartCommand, original.StartCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleBeadID tests role bead ID generation.
|
||||
func TestRoleBeadID(t *testing.T) {
|
||||
tests := []struct {
|
||||
roleType string
|
||||
want string
|
||||
}{
|
||||
{"mayor", "gt-mayor-role"},
|
||||
{"deacon", "gt-deacon-role"},
|
||||
{"witness", "gt-witness-role"},
|
||||
{"refinery", "gt-refinery-role"},
|
||||
{"crew", "gt-crew-role"},
|
||||
{"polecat", "gt-polecat-role"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.roleType, func(t *testing.T) {
|
||||
got := RoleBeadID(tt.roleType)
|
||||
if got != tt.want {
|
||||
t.Errorf("RoleBeadID(%q) = %q, want %q", tt.roleType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,3 +333,125 @@ func SetMRFields(issue *Issue, fields *MRFields) string {
|
||||
|
||||
return formatted + "\n\n" + strings.Join(otherLines, "\n")
|
||||
}
|
||||
|
||||
// RoleConfig holds structured lifecycle configuration for role beads.
|
||||
// These fields are stored as "key: value" lines in the role bead description.
|
||||
// This enables agents to self-register their lifecycle configuration,
|
||||
// replacing hardcoded identity string parsing in the daemon.
|
||||
type RoleConfig struct {
|
||||
// SessionPattern defines how to derive tmux session name.
|
||||
// Supports placeholders: {rig}, {name}, {role}
|
||||
// Examples: "gt-mayor", "gt-{rig}-{role}", "gt-{rig}-{name}"
|
||||
SessionPattern string
|
||||
|
||||
// WorkDirPattern defines the working directory relative to town root.
|
||||
// Supports placeholders: {town}, {rig}, {name}, {role}
|
||||
// Examples: "{town}", "{town}/{rig}", "{town}/{rig}/polecats/{name}"
|
||||
WorkDirPattern string
|
||||
|
||||
// NeedsPreSync indicates whether workspace needs git sync before starting.
|
||||
// True for agents with persistent clones (refinery, crew, polecat).
|
||||
NeedsPreSync bool
|
||||
|
||||
// StartCommand is the command to run after creating the session.
|
||||
// Default: "exec claude --dangerously-skip-permissions"
|
||||
StartCommand string
|
||||
|
||||
// EnvVars are additional environment variables to set in the session.
|
||||
// Stored as "key=value" pairs.
|
||||
EnvVars map[string]string
|
||||
}
|
||||
|
||||
// ParseRoleConfig extracts RoleConfig from a role bead's description.
|
||||
// Fields are expected as "key: value" lines. Returns nil if no config found.
|
||||
func ParseRoleConfig(description string) *RoleConfig {
|
||||
config := &RoleConfig{
|
||||
EnvVars: make(map[string]string),
|
||||
}
|
||||
hasFields := false
|
||||
|
||||
for _, line := range strings.Split(description, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
colonIdx := strings.Index(line, ":")
|
||||
if colonIdx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(line[:colonIdx])
|
||||
value := strings.TrimSpace(line[colonIdx+1:])
|
||||
if value == "" || value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(key) {
|
||||
case "session_pattern", "session-pattern", "sessionpattern":
|
||||
config.SessionPattern = value
|
||||
hasFields = true
|
||||
case "work_dir_pattern", "work-dir-pattern", "workdirpattern", "workdir_pattern":
|
||||
config.WorkDirPattern = value
|
||||
hasFields = true
|
||||
case "needs_pre_sync", "needs-pre-sync", "needspresync":
|
||||
config.NeedsPreSync = strings.ToLower(value) == "true"
|
||||
hasFields = true
|
||||
case "start_command", "start-command", "startcommand":
|
||||
config.StartCommand = value
|
||||
hasFields = true
|
||||
case "env_var", "env-var", "envvar":
|
||||
// Format: "env_var: KEY=VALUE"
|
||||
if eqIdx := strings.Index(value, "="); eqIdx != -1 {
|
||||
envKey := strings.TrimSpace(value[:eqIdx])
|
||||
envVal := strings.TrimSpace(value[eqIdx+1:])
|
||||
config.EnvVars[envKey] = envVal
|
||||
hasFields = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFields {
|
||||
return nil
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// FormatRoleConfig formats RoleConfig as a string suitable for a role bead description.
|
||||
// Only non-empty/non-default fields are included.
|
||||
func FormatRoleConfig(config *RoleConfig) string {
|
||||
if config == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
if config.SessionPattern != "" {
|
||||
lines = append(lines, "session_pattern: "+config.SessionPattern)
|
||||
}
|
||||
if config.WorkDirPattern != "" {
|
||||
lines = append(lines, "work_dir_pattern: "+config.WorkDirPattern)
|
||||
}
|
||||
if config.NeedsPreSync {
|
||||
lines = append(lines, "needs_pre_sync: true")
|
||||
}
|
||||
if config.StartCommand != "" {
|
||||
lines = append(lines, "start_command: "+config.StartCommand)
|
||||
}
|
||||
for k, v := range config.EnvVars {
|
||||
lines = append(lines, "env_var: "+k+"="+v)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ExpandRolePattern expands placeholders in a pattern string.
|
||||
// Supported placeholders: {town}, {rig}, {name}, {role}
|
||||
func ExpandRolePattern(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
|
||||
}
|
||||
|
||||
@@ -216,112 +216,131 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error {
|
||||
}
|
||||
}
|
||||
|
||||
// identityToSession converts a beads identity to a tmux session name.
|
||||
func (d *Daemon) identityToSession(identity string) string {
|
||||
// Handle known identities
|
||||
// ParsedIdentity holds the components extracted from an agent identity string.
|
||||
// This is used to look up the appropriate role bead for lifecycle config.
|
||||
type ParsedIdentity struct {
|
||||
RoleType string // mayor, deacon, witness, refinery, crew, polecat
|
||||
RigName string // Empty for town-level agents (mayor, deacon)
|
||||
AgentName string // Empty for singletons (mayor, deacon, witness, refinery)
|
||||
}
|
||||
|
||||
// parseIdentity extracts role type, rig name, and agent name from an identity string.
|
||||
// This is the ONLY place where identity string patterns are parsed.
|
||||
// All other functions should use the extracted components to look up role beads.
|
||||
func parseIdentity(identity string) (*ParsedIdentity, error) {
|
||||
switch identity {
|
||||
case "mayor":
|
||||
return "gt-mayor"
|
||||
return &ParsedIdentity{RoleType: "mayor"}, nil
|
||||
case "deacon":
|
||||
return &ParsedIdentity{RoleType: "deacon"}, nil
|
||||
}
|
||||
|
||||
// Pattern: <rig>-witness → witness role
|
||||
if strings.HasSuffix(identity, "-witness") {
|
||||
rigName := strings.TrimSuffix(identity, "-witness")
|
||||
return &ParsedIdentity{RoleType: "witness", RigName: rigName}, nil
|
||||
}
|
||||
|
||||
// Pattern: <rig>-refinery → refinery role
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
rigName := strings.TrimSuffix(identity, "-refinery")
|
||||
return &ParsedIdentity{RoleType: "refinery", RigName: rigName}, nil
|
||||
}
|
||||
|
||||
// Pattern: <rig>-crew-<name> → crew role
|
||||
if strings.Contains(identity, "-crew-") {
|
||||
parts := strings.SplitN(identity, "-crew-", 2)
|
||||
if len(parts) == 2 {
|
||||
return &ParsedIdentity{RoleType: "crew", RigName: parts[0], AgentName: parts[1]}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern: <rig>-polecat-<name> → polecat role
|
||||
if strings.Contains(identity, "-polecat-") {
|
||||
parts := strings.SplitN(identity, "-polecat-", 2)
|
||||
if len(parts) == 2 {
|
||||
return &ParsedIdentity{RoleType: "polecat", RigName: parts[0], AgentName: parts[1]}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern: <rig>/polecats/<name> → polecat role (slash format)
|
||||
if strings.Contains(identity, "/polecats/") {
|
||||
parts := strings.Split(identity, "/polecats/")
|
||||
if len(parts) == 2 {
|
||||
return &ParsedIdentity{RoleType: "polecat", RigName: parts[0], AgentName: parts[1]}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown identity format: %s", identity)
|
||||
}
|
||||
|
||||
// getRoleConfigForIdentity looks up the role bead for an identity and returns its config.
|
||||
// Falls back to default config if role bead doesn't exist or has no config.
|
||||
func (d *Daemon) getRoleConfigForIdentity(identity string) (*beads.RoleConfig, *ParsedIdentity, error) {
|
||||
parsed, err := parseIdentity(identity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Look up role bead
|
||||
roleBeadID := beads.RoleBeadID(parsed.RoleType)
|
||||
b := beads.New(d.config.TownRoot)
|
||||
config, err := b.GetRoleConfig(roleBeadID)
|
||||
if err != nil {
|
||||
d.logger.Printf("Warning: failed to get role config for %s: %v", roleBeadID, err)
|
||||
}
|
||||
|
||||
// Return parsed identity even if config is nil (caller can use defaults)
|
||||
return config, parsed, nil
|
||||
}
|
||||
|
||||
// identityToSession converts a beads identity to a tmux session name.
|
||||
// Uses role bead config if available, falls back to hardcoded patterns.
|
||||
func (d *Daemon) identityToSession(identity string) string {
|
||||
config, parsed, err := d.getRoleConfigForIdentity(identity)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If role bead has session_pattern, use it
|
||||
if config != nil && config.SessionPattern != "" {
|
||||
return beads.ExpandRolePattern(config.SessionPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
}
|
||||
|
||||
// Fallback: use default patterns based on role type
|
||||
switch parsed.RoleType {
|
||||
case "mayor", "deacon":
|
||||
return "gt-" + parsed.RoleType
|
||||
case "witness", "refinery":
|
||||
return fmt.Sprintf("gt-%s-%s", parsed.RigName, parsed.RoleType)
|
||||
case "crew":
|
||||
return fmt.Sprintf("gt-%s-crew-%s", parsed.RigName, parsed.AgentName)
|
||||
case "polecat":
|
||||
return fmt.Sprintf("gt-%s-%s", parsed.RigName, parsed.AgentName)
|
||||
default:
|
||||
// Pattern: <rig>-witness → gt-<rig>-witness
|
||||
if strings.HasSuffix(identity, "-witness") {
|
||||
return "gt-" + identity
|
||||
}
|
||||
// Pattern: <rig>-refinery → gt-<rig>-refinery
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
return "gt-" + identity
|
||||
}
|
||||
// Pattern: <rig>-crew-<name> → gt-<rig>-crew-<name>
|
||||
if strings.Contains(identity, "-crew-") {
|
||||
return "gt-" + identity
|
||||
}
|
||||
// Pattern: <rig>-polecat-<name> or <rig>/polecats/<name> → gt-<rig>-<name>
|
||||
if strings.Contains(identity, "-polecat-") {
|
||||
// <rig>-polecat-<name> → gt-<rig>-<name>
|
||||
parts := strings.SplitN(identity, "-polecat-", 2)
|
||||
if len(parts) == 2 {
|
||||
return fmt.Sprintf("gt-%s-%s", parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
if strings.Contains(identity, "/polecats/") {
|
||||
// <rig>/polecats/<name> → gt-<rig>-<name>
|
||||
parts := strings.Split(identity, "/polecats/")
|
||||
if len(parts) == 2 {
|
||||
return fmt.Sprintf("gt-%s-%s", parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
// Unknown identity
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// restartSession starts a new session for the given agent.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
// Determine working directory and startup command based on agent type
|
||||
var workDir, startCmd string
|
||||
var rigName string
|
||||
var agentRole string
|
||||
var needsPreSync bool
|
||||
|
||||
if identity == "mayor" {
|
||||
workDir = d.config.TownRoot
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "coordinator"
|
||||
} else if strings.HasSuffix(identity, "-witness") {
|
||||
// Extract rig name: <rig>-witness → <rig>
|
||||
rigName = strings.TrimSuffix(identity, "-witness")
|
||||
workDir = d.config.TownRoot + "/" + rigName
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "witness"
|
||||
} else if strings.HasSuffix(identity, "-refinery") {
|
||||
// Extract rig name: <rig>-refinery → <rig>
|
||||
rigName = strings.TrimSuffix(identity, "-refinery")
|
||||
workDir = filepath.Join(d.config.TownRoot, rigName, "refinery", "rig")
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "refinery"
|
||||
needsPreSync = true
|
||||
} else if strings.Contains(identity, "-crew-") {
|
||||
// Extract rig and crew name: <rig>-crew-<name> → <rig>, <name>
|
||||
parts := strings.SplitN(identity, "-crew-", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid crew identity format: %s", identity)
|
||||
}
|
||||
rigName = parts[0]
|
||||
crewName := parts[1]
|
||||
workDir = filepath.Join(d.config.TownRoot, rigName, "crew", crewName)
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "crew"
|
||||
needsPreSync = true
|
||||
} else if strings.Contains(identity, "-polecat-") || strings.Contains(identity, "/polecats/") {
|
||||
// Extract rig and polecat name from either format:
|
||||
// <rig>-polecat-<name> or <rig>/polecats/<name>
|
||||
var polecatName string
|
||||
if strings.Contains(identity, "-polecat-") {
|
||||
parts := strings.SplitN(identity, "-polecat-", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid polecat identity format: %s", identity)
|
||||
}
|
||||
rigName = parts[0]
|
||||
polecatName = parts[1]
|
||||
} else {
|
||||
parts := strings.Split(identity, "/polecats/")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid polecat identity format: %s", identity)
|
||||
}
|
||||
rigName = parts[0]
|
||||
polecatName = parts[1]
|
||||
}
|
||||
workDir = filepath.Join(d.config.TownRoot, rigName, "polecats", polecatName)
|
||||
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
|
||||
startCmd = fmt.Sprintf("export GT_ROLE=polecat GT_RIG=%s GT_POLECAT=%s BD_ACTOR=%s && claude --dangerously-skip-permissions",
|
||||
rigName, polecatName, bdActor)
|
||||
agentRole = "polecat"
|
||||
needsPreSync = true
|
||||
} else {
|
||||
return fmt.Errorf("don't know how to restart %s", identity)
|
||||
// Get role config for this identity
|
||||
config, parsed, err := d.getRoleConfigForIdentity(identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing identity: %w", err)
|
||||
}
|
||||
|
||||
// Pre-sync workspace for agents with git worktrees (refinery)
|
||||
// Determine working directory
|
||||
workDir := d.getWorkDir(config, parsed)
|
||||
if workDir == "" {
|
||||
return fmt.Errorf("cannot determine working directory for %s", identity)
|
||||
}
|
||||
|
||||
// Determine if pre-sync is needed
|
||||
needsPreSync := d.getNeedsPreSync(config, parsed)
|
||||
|
||||
// Pre-sync workspace for agents with git worktrees
|
||||
if needsPreSync {
|
||||
d.logger.Printf("Pre-syncing workspace for %s at %s", identity, workDir)
|
||||
d.syncWorkspace(workDir)
|
||||
@@ -332,22 +351,14 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
}
|
||||
|
||||
// Set environment (non-fatal: session works without these)
|
||||
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity)
|
||||
// BD_ACTOR uses slashes instead of dashes for path-like identity
|
||||
bdActor := identityToBDActor(identity)
|
||||
_ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
|
||||
// Set environment variables
|
||||
d.setSessionEnvironment(sessionName, identity, config, parsed)
|
||||
|
||||
// Apply theme (non-fatal: theming failure doesn't affect operation)
|
||||
if identity == "mayor" {
|
||||
theme := tmux.MayorTheme()
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
|
||||
} else if rigName != "" {
|
||||
theme := tmux.AssignTheme(rigName)
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, rigName, agentRole, agentRole)
|
||||
}
|
||||
d.applySessionTheme(sessionName, parsed)
|
||||
|
||||
// Send startup command
|
||||
// Get and send startup command
|
||||
startCmd := d.getStartCommand(config, parsed)
|
||||
if err := d.tmux.SendKeys(sessionName, startCmd); err != nil {
|
||||
return fmt.Errorf("sending startup command: %w", err)
|
||||
}
|
||||
@@ -358,6 +369,102 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getWorkDir determines the working directory for an agent.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) string {
|
||||
// If role bead has work_dir_pattern, use it
|
||||
if config != nil && config.WorkDirPattern != "" {
|
||||
return beads.ExpandRolePattern(config.WorkDirPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
}
|
||||
|
||||
// Fallback: use default patterns based on role type
|
||||
switch parsed.RoleType {
|
||||
case "mayor":
|
||||
return d.config.TownRoot
|
||||
case "deacon":
|
||||
return d.config.TownRoot
|
||||
case "witness":
|
||||
return filepath.Join(d.config.TownRoot, parsed.RigName)
|
||||
case "refinery":
|
||||
return filepath.Join(d.config.TownRoot, parsed.RigName, "refinery", "rig")
|
||||
case "crew":
|
||||
return filepath.Join(d.config.TownRoot, parsed.RigName, "crew", parsed.AgentName)
|
||||
case "polecat":
|
||||
return filepath.Join(d.config.TownRoot, parsed.RigName, "polecats", parsed.AgentName)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// getNeedsPreSync determines if a workspace needs git sync before starting.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentity) bool {
|
||||
// If role bead has explicit config, use it
|
||||
if config != nil {
|
||||
return config.NeedsPreSync
|
||||
}
|
||||
|
||||
// Fallback: roles with persistent git clones need pre-sync
|
||||
switch parsed.RoleType {
|
||||
case "refinery", "crew", "polecat":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// getStartCommand determines the startup command for an agent.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) getStartCommand(config *beads.RoleConfig, parsed *ParsedIdentity) string {
|
||||
// If role bead has explicit config, use it
|
||||
if config != nil && config.StartCommand != "" {
|
||||
// Expand any patterns in the command
|
||||
return beads.ExpandRolePattern(config.StartCommand, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
}
|
||||
|
||||
// Default command for all agents
|
||||
defaultCmd := "exec claude --dangerously-skip-permissions"
|
||||
|
||||
// Polecats need environment variables set in the command
|
||||
if parsed.RoleType == "polecat" {
|
||||
bdActor := fmt.Sprintf("%s/polecats/%s", parsed.RigName, parsed.AgentName)
|
||||
return fmt.Sprintf("export GT_ROLE=polecat GT_RIG=%s GT_POLECAT=%s BD_ACTOR=%s && %s",
|
||||
parsed.RigName, parsed.AgentName, bdActor, defaultCmd)
|
||||
}
|
||||
|
||||
return defaultCmd
|
||||
}
|
||||
|
||||
// setSessionEnvironment sets environment variables for the tmux session.
|
||||
// Uses role bead config if available, falls back to hardcoded defaults.
|
||||
func (d *Daemon) setSessionEnvironment(sessionName, identity string, config *beads.RoleConfig, parsed *ParsedIdentity) {
|
||||
// Always set GT_ROLE
|
||||
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity)
|
||||
|
||||
// BD_ACTOR uses slashes instead of dashes for path-like identity
|
||||
bdActor := identityToBDActor(identity)
|
||||
_ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
|
||||
|
||||
// Set any custom env vars from role config
|
||||
if config != nil {
|
||||
for k, v := range config.EnvVars {
|
||||
expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
|
||||
_ = d.tmux.SetEnvironment(sessionName, k, expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applySessionTheme applies tmux theming to the session.
|
||||
func (d *Daemon) applySessionTheme(sessionName string, parsed *ParsedIdentity) {
|
||||
if parsed.RoleType == "mayor" {
|
||||
theme := tmux.MayorTheme()
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
|
||||
} else if parsed.RigName != "" {
|
||||
theme := tmux.AssignTheme(parsed.RigName)
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, parsed.RigName, parsed.RoleType, parsed.RoleType)
|
||||
}
|
||||
}
|
||||
|
||||
// syncWorkspace syncs a git workspace before starting a new session.
|
||||
// This ensures agents with persistent clones (like refinery) start with current code.
|
||||
func (d *Daemon) syncWorkspace(workDir string) {
|
||||
@@ -480,50 +587,32 @@ func (d *Daemon) clearAgentRequestingState(identity string, action LifecycleActi
|
||||
}
|
||||
|
||||
// identityToStateFile maps an agent identity to its state.json file path.
|
||||
// Uses parseIdentity to extract components, then derives state file location.
|
||||
func (d *Daemon) identityToStateFile(identity string) string {
|
||||
switch identity {
|
||||
parsed, err := parseIdentity(identity)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Derive state file path based on working directory
|
||||
workDir := d.getWorkDir(nil, parsed) // Use defaults, not role bead config
|
||||
if workDir == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// For mayor and deacon, state file is in a subdirectory
|
||||
switch parsed.RoleType {
|
||||
case "mayor":
|
||||
return filepath.Join(d.config.TownRoot, "mayor", "state.json")
|
||||
case "deacon":
|
||||
return filepath.Join(d.config.TownRoot, "deacon", "state.json")
|
||||
case "witness":
|
||||
return filepath.Join(d.config.TownRoot, parsed.RigName, "witness", "state.json")
|
||||
case "refinery":
|
||||
return filepath.Join(d.config.TownRoot, parsed.RigName, "refinery", "state.json")
|
||||
default:
|
||||
// Pattern: <rig>-witness → <townRoot>/<rig>/witness/state.json
|
||||
if strings.HasSuffix(identity, "-witness") {
|
||||
rigName := strings.TrimSuffix(identity, "-witness")
|
||||
return filepath.Join(d.config.TownRoot, rigName, "witness", "state.json")
|
||||
}
|
||||
// Pattern: <rig>-refinery → <townRoot>/<rig>/refinery/state.json
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
rigName := strings.TrimSuffix(identity, "-refinery")
|
||||
return filepath.Join(d.config.TownRoot, rigName, "refinery", "state.json")
|
||||
}
|
||||
// Pattern: <rig>-crew-<name> → <townRoot>/<rig>/crew/<name>/state.json
|
||||
if strings.Contains(identity, "-crew-") {
|
||||
parts := strings.SplitN(identity, "-crew-", 2)
|
||||
if len(parts) == 2 {
|
||||
rigName := parts[0]
|
||||
crewName := parts[1]
|
||||
return filepath.Join(d.config.TownRoot, rigName, "crew", crewName, "state.json")
|
||||
}
|
||||
}
|
||||
// Pattern: <rig>-polecat-<name> → <townRoot>/<rig>/polecats/<name>/state.json
|
||||
if strings.Contains(identity, "-polecat-") {
|
||||
parts := strings.SplitN(identity, "-polecat-", 2)
|
||||
if len(parts) == 2 {
|
||||
rigName := parts[0]
|
||||
polecatName := parts[1]
|
||||
return filepath.Join(d.config.TownRoot, rigName, "polecats", polecatName, "state.json")
|
||||
}
|
||||
}
|
||||
// Pattern: <rig>/polecats/<name> → <townRoot>/<rig>/polecats/<name>/state.json
|
||||
if strings.Contains(identity, "/polecats/") {
|
||||
parts := strings.Split(identity, "/polecats/")
|
||||
if len(parts) == 2 {
|
||||
rigName := parts[0]
|
||||
polecatName := parts[1]
|
||||
return filepath.Join(d.config.TownRoot, rigName, "polecats", polecatName, "state.json")
|
||||
}
|
||||
}
|
||||
// Unknown identity - can't determine state file
|
||||
return ""
|
||||
// For crew and polecat, state file is in their working directory
|
||||
return filepath.Join(workDir, "state.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,52 +691,27 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
|
||||
}
|
||||
|
||||
// identityToAgentBeadID maps a daemon identity to an agent bead ID.
|
||||
// Uses the canonical naming convention: prefix-rig-role-name
|
||||
// Examples:
|
||||
// - "deacon" → "gt-deacon"
|
||||
// - "mayor" → "gt-mayor"
|
||||
// - "gastown-witness" → "gt-gastown-witness"
|
||||
// - "gastown-refinery" → "gt-gastown-refinery"
|
||||
// - "gastown-polecat-toast" → "gt-polecat-gastown-toast"
|
||||
// Uses parseIdentity to extract components, then uses beads package helpers.
|
||||
func (d *Daemon) identityToAgentBeadID(identity string) string {
|
||||
switch identity {
|
||||
parsed, err := parseIdentity(identity)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch parsed.RoleType {
|
||||
case "deacon":
|
||||
return beads.DeaconBeadID()
|
||||
case "mayor":
|
||||
return beads.MayorBeadID()
|
||||
case "witness":
|
||||
return beads.WitnessBeadID(parsed.RigName)
|
||||
case "refinery":
|
||||
return beads.RefineryBeadID(parsed.RigName)
|
||||
case "crew":
|
||||
return beads.CrewBeadID(parsed.RigName, parsed.AgentName)
|
||||
case "polecat":
|
||||
return beads.PolecatBeadID(parsed.RigName, parsed.AgentName)
|
||||
default:
|
||||
// Pattern: <rig>-witness → gt-<rig>-witness
|
||||
if strings.HasSuffix(identity, "-witness") {
|
||||
rigName := strings.TrimSuffix(identity, "-witness")
|
||||
return beads.WitnessBeadID(rigName)
|
||||
}
|
||||
// Pattern: <rig>-refinery → gt-<rig>-refinery
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
rigName := strings.TrimSuffix(identity, "-refinery")
|
||||
return beads.RefineryBeadID(rigName)
|
||||
}
|
||||
// Pattern: <rig>-crew-<name> → gt-<rig>-crew-<name>
|
||||
if strings.Contains(identity, "-crew-") {
|
||||
parts := strings.SplitN(identity, "-crew-", 2)
|
||||
if len(parts) == 2 {
|
||||
return beads.CrewBeadID(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
// Pattern: <rig>-polecat-<name> → gt-polecat-<rig>-<name>
|
||||
if strings.Contains(identity, "-polecat-") {
|
||||
parts := strings.SplitN(identity, "-polecat-", 2)
|
||||
if len(parts) == 2 {
|
||||
return beads.PolecatBeadID(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
// Pattern: <rig>/polecats/<name> → gt-polecat-<rig>-<name>
|
||||
if strings.Contains(identity, "/polecats/") {
|
||||
parts := strings.Split(identity, "/polecats/")
|
||||
if len(parts) == 2 {
|
||||
return beads.PolecatBeadID(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
// Unknown format
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -740,47 +804,32 @@ func (d *Daemon) markAgentDead(agentBeadID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// identityToBDActor converts a daemon identity (with dashes) to BD_ACTOR format (with slashes).
|
||||
// Examples:
|
||||
// - "mayor" → "mayor"
|
||||
// - "gastown-witness" → "gastown/witness"
|
||||
// - "gastown-refinery" → "gastown/refinery"
|
||||
// - "gastown-crew-max" → "gastown/crew/max"
|
||||
// - "gastown-polecat-toast" → "gastown/polecats/toast"
|
||||
// identityToBDActor converts a daemon identity to BD_ACTOR format (with slashes).
|
||||
// Uses parseIdentity to extract components, then builds the slash format.
|
||||
func identityToBDActor(identity string) string {
|
||||
switch identity {
|
||||
case "mayor", "deacon":
|
||||
// Handle already-slash-formatted identities
|
||||
if strings.Contains(identity, "/polecats/") || strings.Contains(identity, "/crew/") ||
|
||||
strings.Contains(identity, "/witness") || strings.Contains(identity, "/refinery") {
|
||||
return identity
|
||||
}
|
||||
|
||||
parsed, err := parseIdentity(identity)
|
||||
if err != nil {
|
||||
return identity // Unknown format - return as-is
|
||||
}
|
||||
|
||||
switch parsed.RoleType {
|
||||
case "mayor", "deacon":
|
||||
return parsed.RoleType
|
||||
case "witness":
|
||||
return parsed.RigName + "/witness"
|
||||
case "refinery":
|
||||
return parsed.RigName + "/refinery"
|
||||
case "crew":
|
||||
return parsed.RigName + "/crew/" + parsed.AgentName
|
||||
case "polecat":
|
||||
return parsed.RigName + "/polecats/" + parsed.AgentName
|
||||
default:
|
||||
// Pattern: <rig>-witness → <rig>/witness
|
||||
if strings.HasSuffix(identity, "-witness") {
|
||||
rigName := strings.TrimSuffix(identity, "-witness")
|
||||
return rigName + "/witness"
|
||||
}
|
||||
// Pattern: <rig>-refinery → <rig>/refinery
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
rigName := strings.TrimSuffix(identity, "-refinery")
|
||||
return rigName + "/refinery"
|
||||
}
|
||||
// Pattern: <rig>-crew-<name> → <rig>/crew/<name>
|
||||
if strings.Contains(identity, "-crew-") {
|
||||
parts := strings.SplitN(identity, "-crew-", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0] + "/crew/" + parts[1]
|
||||
}
|
||||
}
|
||||
// Pattern: <rig>-polecat-<name> → <rig>/polecats/<name>
|
||||
if strings.Contains(identity, "-polecat-") {
|
||||
parts := strings.SplitN(identity, "-polecat-", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0] + "/polecats/" + parts[1]
|
||||
}
|
||||
}
|
||||
// Identity already in slash format - return as-is
|
||||
if strings.Contains(identity, "/polecats/") {
|
||||
return identity
|
||||
}
|
||||
// Unknown format - return as-is
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user