refactor: Move agent field parsing to shared beads package

- Add AgentFields and ParseAgentFieldsFromDescription to internal/beads/fields.go
- Update daemon/lifecycle.go to use shared parsing
- Update cmd/molecule_status.go to use shared parsing
- Remove duplicate parsing code and unused isAgentRunningByBead function

This consolidates agent bead field parsing in one place, following the pattern
established for AttachmentFields and MRFields.

🤖 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-28 01:43:27 -08:00
parent 4f5b7bf519
commit a1715fa91f
3 changed files with 120 additions and 118 deletions

View File

@@ -3,6 +3,105 @@ package beads
import "strings" import "strings"
// AgentFields holds parsed fields from an agent bead's description.
// Agent beads store their state as key: value lines in the description.
type AgentFields struct {
RoleType string // role_type: mayor, deacon, witness, refinery, polecat
Rig string // rig: gastown (or null)
AgentState string // agent_state: idle, running, working, stopped
HookBead string // hook_bead: the bead ID on the hook (or null)
RoleBead string // role_bead: the role definition bead
}
// ParseAgentFields extracts agent fields from an issue's description.
// Fields are expected as "key: value" lines. Returns nil if no agent fields found.
func ParseAgentFields(issue *Issue) *AgentFields {
if issue == nil || issue.Description == "" {
return nil
}
return ParseAgentFieldsFromDescription(issue.Description)
}
// ParseAgentFieldsFromDescription extracts agent fields from a description string.
// Returns nil if no agent fields found.
func ParseAgentFieldsFromDescription(description string) *AgentFields {
if description == "" {
return nil
}
fields := &AgentFields{}
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 "role_type", "role-type", "roletype":
fields.RoleType = value
hasFields = true
case "rig":
fields.Rig = value
hasFields = true
case "agent_state", "agent-state", "agentstate":
fields.AgentState = value
hasFields = true
case "hook_bead", "hook-bead", "hookbead":
fields.HookBead = value
hasFields = true
case "role_bead", "role-bead", "rolebead":
fields.RoleBead = value
hasFields = true
}
}
if !hasFields {
return nil
}
return fields
}
// FormatAgentFields formats AgentFields as a string suitable for an issue description.
// Only non-empty fields are included.
func FormatAgentFields(fields *AgentFields) string {
if fields == nil {
return ""
}
var lines []string
if fields.RoleType != "" {
lines = append(lines, "role_type: "+fields.RoleType)
}
if fields.Rig != "" {
lines = append(lines, "rig: "+fields.Rig)
}
if fields.AgentState != "" {
lines = append(lines, "agent_state: "+fields.AgentState)
}
if fields.HookBead != "" {
lines = append(lines, "hook_bead: "+fields.HookBead)
}
if fields.RoleBead != "" {
lines = append(lines, "role_bead: "+fields.RoleBead)
}
return strings.Join(lines, "\n")
}
// AttachmentFields holds the attachment info for pinned beads. // AttachmentFields holds the attachment info for pinned beads.
// These fields track which molecule is attached to a handoff/pinned bead. // These fields track which molecule is attached to a handoff/pinned bead.
type AttachmentFields struct { type AttachmentFields struct {

View File

@@ -14,67 +14,7 @@ import (
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
// AgentBeadFields holds parsed fields from an agent bead's description. // Note: Agent field parsing is now in internal/beads/fields.go (AgentFields, ParseAgentFieldsFromDescription)
// Agent beads store their state as key: value lines in the description.
type AgentBeadFields struct {
RoleType string // role_type: mayor, deacon, witness, refinery, polecat
Rig string // rig: gastown (or null)
AgentState string // agent_state: idle, working, done
HookBead string // hook_bead: the bead ID on the hook (or null)
RoleBead string // role_bead: the role definition bead
}
// ParseAgentBeadFields extracts agent bead fields from a bead's description.
// Returns nil if no agent fields found.
func ParseAgentBeadFields(description string) *AgentBeadFields {
if description == "" {
return nil
}
fields := &AgentBeadFields{}
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 "role_type":
fields.RoleType = value
hasFields = true
case "rig":
fields.Rig = value
hasFields = true
case "agent_state":
fields.AgentState = value
hasFields = true
case "hook_bead":
fields.HookBead = value
hasFields = true
case "role_bead":
fields.RoleBead = value
hasFields = true
}
}
if !hasFields {
return nil
}
return fields
}
// buildAgentBeadID constructs the agent bead ID from an agent identity. // buildAgentBeadID constructs the agent bead ID from an agent identity.
// Examples: // Examples:
@@ -388,7 +328,7 @@ func runMoleculeStatus(cmd *cobra.Command, args []string) error {
status.AgentBeadID = agentBeadID status.AgentBeadID = agentBeadID
// Parse hook_bead from the agent bead's description // Parse hook_bead from the agent bead's description
agentFields := ParseAgentBeadFields(agentBead.Description) agentFields := beads.ParseAgentFieldsFromDescription(agentBead.Description)
if agentFields != nil && agentFields.HookBead != "" { if agentFields != nil && agentFields.HookBead != "" {
// Fetch the bead on the hook // Fetch the bead on the hook
hookBead, err = b.Show(agentFields.HookBead) hookBead, err = b.Show(agentFields.HookBead)

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
@@ -501,60 +502,41 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
} }
// bd show --json returns an array with one element // bd show --json returns an array with one element
var beads []struct { var issues []struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"issue_type"` Type string `json:"issue_type"`
Description string `json:"description"` Description string `json:"description"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
if err := json.Unmarshal(output, &beads); err != nil { if err := json.Unmarshal(output, &issues); err != nil {
return nil, fmt.Errorf("parsing bd show output: %w", err) return nil, fmt.Errorf("parsing bd show output: %w", err)
} }
if len(beads) == 0 { if len(issues) == 0 {
return nil, fmt.Errorf("agent bead not found: %s", agentBeadID) return nil, fmt.Errorf("agent bead not found: %s", agentBeadID)
} }
bead := beads[0] issue := issues[0]
if bead.Type != "agent" { if issue.Type != "agent" {
return nil, fmt.Errorf("bead %s is not an agent bead (type=%s)", agentBeadID, bead.Type) return nil, fmt.Errorf("bead %s is not an agent bead (type=%s)", agentBeadID, issue.Type)
} }
// Parse agent fields from description (YAML-like format) // Use shared parsing from beads package
fields := beads.ParseAgentFieldsFromDescription(issue.Description)
info := &AgentBeadInfo{ info := &AgentBeadInfo{
ID: bead.ID, ID: issue.ID,
Type: bead.Type, Type: issue.Type,
LastUpdate: bead.UpdatedAt, LastUpdate: issue.UpdatedAt,
} }
for _, line := range strings.Split(bead.Description, "\n") { if fields != nil {
line = strings.TrimSpace(line) info.State = fields.AgentState
if line == "" { info.HookBead = fields.HookBead
continue info.RoleBead = fields.RoleBead
} info.RoleType = fields.RoleType
colonIdx := strings.Index(line, ":") info.Rig = fields.Rig
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 "agent_state":
info.State = value
case "hook_bead":
info.HookBead = value
case "role_bead":
info.RoleBead = value
case "role_type":
info.RoleType = value
case "rig":
info.Rig = value
}
} }
return info, nil return info, nil
@@ -595,25 +577,6 @@ func (d *Daemon) identityToAgentBeadID(identity string) string {
} }
} }
// isAgentRunningByBead checks if an agent reports itself as running via its agent bead.
// Returns (running, found) where found indicates if the agent bead exists.
func (d *Daemon) isAgentRunningByBead(identity string) (bool, bool) {
agentBeadID := d.identityToAgentBeadID(identity)
if agentBeadID == "" {
return false, false
}
state, err := d.getAgentBeadState(agentBeadID)
if err != nil {
// Agent bead not found or not readable
return false, false
}
// Consider "running" or "working" as running states
running := state == "running" || state == "working"
return running, true
}
// identityToBDActor converts a daemon identity (with dashes) to BD_ACTOR format (with slashes). // identityToBDActor converts a daemon identity (with dashes) to BD_ACTOR format (with slashes).
// Examples: // Examples:
// - "mayor" → "mayor" // - "mayor" → "mayor"