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:
Steve Yegge
2025-12-30 02:06:08 -08:00
parent ede5406d36
commit 437d42c7fa
4 changed files with 766 additions and 226 deletions

View File

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

View File

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

View File

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