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:
Steve Yegge
2025-12-28 01:37:06 -08:00
parent d150f339d2
commit b4765c0c69
2 changed files with 201 additions and 0 deletions

View File

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

View File

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