feat: Add polecat agent bead lifecycle (gt-rxa7v)
Create ephemeral agent beads for ZFC-compliant polecat state tracking. - Add AgentFields struct and helpers to beads package - CreateAgentBead: Creates agent bead with role_type/rig/agent_state - UpdateAgentState: Updates agent_state and hook_bead fields - DeleteAgentBead: Hard-deletes ephemeral agent bead - GetAgentBead: Retrieves and parses agent bead - Integrate lifecycle in polecat manager: - Add(): Creates gt-polecat-<rig>-<name> bead with state=spawning - Recreate(): Deletes old bead, creates fresh with state=spawning - RemoveWithOptions(): Deletes agent bead on nuke This enables Witness to read polecat state from beads instead of tmux scraping. State updates (spawning→working→done) are done by polecats via bd agent state (separate beads CLI enhancement). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -510,3 +510,158 @@ func (b *Beads) IsBeadsRepo() bool {
|
||||
_, err := b.run("list", "--limit=1")
|
||||
return err == nil || !errors.Is(err, ErrNotARepo)
|
||||
}
|
||||
|
||||
// AgentFields holds structured fields for agent beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type AgentFields struct {
|
||||
RoleType string // polecat, witness, refinery, deacon, mayor
|
||||
Rig string // Rig name (empty for global agents like mayor/deacon)
|
||||
AgentState string // spawning, working, done, stuck
|
||||
HookBead string // Currently pinned work bead ID
|
||||
RoleBead string // Role definition bead ID
|
||||
}
|
||||
|
||||
// FormatAgentDescription creates a description string from agent fields.
|
||||
func FormatAgentDescription(title string, fields *AgentFields) string {
|
||||
if fields == nil {
|
||||
return title
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, title)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("role_type: %s", fields.RoleType))
|
||||
|
||||
if fields.Rig != "" {
|
||||
lines = append(lines, fmt.Sprintf("rig: %s", fields.Rig))
|
||||
} else {
|
||||
lines = append(lines, "rig: null")
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("agent_state: %s", fields.AgentState))
|
||||
|
||||
if fields.HookBead != "" {
|
||||
lines = append(lines, fmt.Sprintf("hook_bead: %s", fields.HookBead))
|
||||
} else {
|
||||
lines = append(lines, "hook_bead: null")
|
||||
}
|
||||
|
||||
if fields.RoleBead != "" {
|
||||
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
||||
} else {
|
||||
lines = append(lines, "role_bead: null")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ParseAgentFields extracts agent fields from an issue's description.
|
||||
func ParseAgentFields(description string) *AgentFields {
|
||||
fields := &AgentFields{}
|
||||
|
||||
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 == "null" || value == "" {
|
||||
value = ""
|
||||
}
|
||||
|
||||
switch strings.ToLower(key) {
|
||||
case "role_type":
|
||||
fields.RoleType = value
|
||||
case "rig":
|
||||
fields.Rig = value
|
||||
case "agent_state":
|
||||
fields.AgentState = value
|
||||
case "hook_bead":
|
||||
fields.HookBead = value
|
||||
case "role_bead":
|
||||
fields.RoleBead = value
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
||||
// The ID format is: <prefix>-<role>-<rig>-<name> (e.g., gt-polecat-gastown-Toast)
|
||||
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||
description := FormatAgentDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
"--id=" + id,
|
||||
"--type=agent",
|
||||
"--title=" + title,
|
||||
"--description=" + description,
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issue Issue
|
||||
if err := json.Unmarshal(out, &issue); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// UpdateAgentState updates the agent_state field in an agent bead.
|
||||
// Optionally updates hook_bead if provided.
|
||||
func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) error {
|
||||
// First get current issue to preserve other fields
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse existing fields
|
||||
fields := ParseAgentFields(issue.Description)
|
||||
fields.AgentState = state
|
||||
if hookBead != nil {
|
||||
fields.HookBead = *hookBead
|
||||
}
|
||||
|
||||
// Format new description
|
||||
description := FormatAgentDescription(issue.Title, fields)
|
||||
|
||||
return b.Update(id, UpdateOptions{Description: &description})
|
||||
}
|
||||
|
||||
// DeleteAgentBead permanently deletes an agent bead.
|
||||
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
||||
func (b *Beads) DeleteAgentBead(id string) error {
|
||||
_, err := b.run("delete", id, "--hard", "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAgentBead retrieves an agent bead by ID.
|
||||
// Returns nil if not found.
|
||||
func (b *Beads) GetAgentBead(id string) (*Issue, *AgentFields, error) {
|
||||
issue, err := b.Show(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if issue.Type != "agent" {
|
||||
return nil, nil, fmt.Errorf("issue %s is not an agent bead (type: %s)", id, issue.Type)
|
||||
}
|
||||
|
||||
fields := ParseAgentFields(issue.Description)
|
||||
return issue, fields, nil
|
||||
}
|
||||
|
||||
@@ -82,6 +82,12 @@ func (m *Manager) assigneeID(name string) string {
|
||||
return fmt.Sprintf("%s/%s", m.rig.Name, name)
|
||||
}
|
||||
|
||||
// agentBeadID returns the agent bead ID for a polecat.
|
||||
// Format: "gt-polecat-<rig>-<name>" (e.g., "gt-polecat-gastown-Toast")
|
||||
func (m *Manager) agentBeadID(name string) string {
|
||||
return fmt.Sprintf("gt-polecat-%s-%s", m.rig.Name, name)
|
||||
}
|
||||
|
||||
// repoBase returns the git directory and Git object to use for worktree operations.
|
||||
// Prefers the shared bare repo (.repo.git) if it exists, otherwise falls back to mayor/rig.
|
||||
// The bare repo architecture allows all worktrees (refinery, polecats) to share branch visibility.
|
||||
@@ -155,6 +161,19 @@ func (m *Manager) Add(name string) (*Polecat, error) {
|
||||
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
||||
}
|
||||
|
||||
// Create agent bead for ZFC compliance (self-report state).
|
||||
// State starts as "spawning" - will be updated to "working" when Claude starts.
|
||||
agentID := m.agentBeadID(name)
|
||||
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: m.rig.Name,
|
||||
AgentState: "spawning",
|
||||
})
|
||||
if err != nil {
|
||||
// Non-fatal - log warning but continue
|
||||
fmt.Printf("Warning: could not create agent bead: %v\n", err)
|
||||
}
|
||||
|
||||
// Return polecat with derived state (no issue assigned yet = idle)
|
||||
// State is derived from beads, not stored in state.json
|
||||
now := time.Now()
|
||||
@@ -228,6 +247,15 @@ func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
|
||||
m.namePool.Release(name)
|
||||
_ = m.namePool.Save()
|
||||
|
||||
// Delete agent bead (non-fatal: may not exist or beads may not be available)
|
||||
agentID := m.agentBeadID(name)
|
||||
if err := m.beads.DeleteAgentBead(agentID); err != nil {
|
||||
// Only log if not "not found" - it's ok if it doesn't exist
|
||||
if !errors.Is(err, beads.ErrNotFound) {
|
||||
fmt.Printf("Warning: could not delete agent bead %s: %v\n", agentID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -286,6 +314,14 @@ func (m *Manager) Recreate(name string, force bool) (*Polecat, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old agent bead before recreation (non-fatal)
|
||||
agentID := m.agentBeadID(name)
|
||||
if err := m.beads.DeleteAgentBead(agentID); err != nil {
|
||||
if !errors.Is(err, beads.ErrNotFound) {
|
||||
fmt.Printf("Warning: could not delete old agent bead %s: %v\n", agentID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the worktree (use force for git worktree removal)
|
||||
if err := repoGit.WorktreeRemove(polecatPath, true); err != nil {
|
||||
// Fall back to direct removal
|
||||
@@ -313,6 +349,16 @@ func (m *Manager) Recreate(name string, force bool) (*Polecat, error) {
|
||||
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
||||
}
|
||||
|
||||
// Create fresh agent bead for ZFC compliance
|
||||
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
|
||||
RoleType: "polecat",
|
||||
Rig: m.rig.Name,
|
||||
AgentState: "spawning",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not create agent bead: %v\n", err)
|
||||
}
|
||||
|
||||
// Return fresh polecat
|
||||
now := time.Now()
|
||||
return &Polecat{
|
||||
|
||||
Reference in New Issue
Block a user