Break down monolithic beads.go and mail.go into smaller, single-purpose files: beads package: - beads_agent.go: Agent-related bead operations - beads_delegation.go: Delegation bead handling - beads_dog.go: Dog pool operations - beads_merge_slot.go: Merge slot management - beads_mr.go: Merge request operations - beads_redirect.go: Redirect bead handling - beads_rig.go: Rig bead operations - beads_role.go: Role bead management cmd package: - mail_announce.go: Announcement subcommand - mail_check.go: Mail check subcommand - mail_identity.go: Identity management - mail_inbox.go: Inbox operations - mail_queue.go: Queue subcommand - mail_search.go: Search functionality - mail_send.go: Send subcommand - mail_thread.go: Thread operations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
385 lines
12 KiB
Go
385 lines
12 KiB
Go
// Package beads provides agent bead management.
|
|
package beads
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// 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 (canonical location; may not exist yet)
|
|
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
|
ActiveMR string // Currently active merge request bead ID (for traceability)
|
|
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
|
}
|
|
|
|
// Notification level constants
|
|
const (
|
|
NotifyVerbose = "verbose" // All notifications (mail, convoy events, etc.)
|
|
NotifyNormal = "normal" // Important events only (default)
|
|
NotifyMuted = "muted" // Silent/DND mode - batch for later
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
|
|
if fields.CleanupStatus != "" {
|
|
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
|
} else {
|
|
lines = append(lines, "cleanup_status: null")
|
|
}
|
|
|
|
if fields.ActiveMR != "" {
|
|
lines = append(lines, fmt.Sprintf("active_mr: %s", fields.ActiveMR))
|
|
} else {
|
|
lines = append(lines, "active_mr: null")
|
|
}
|
|
|
|
if fields.NotificationLevel != "" {
|
|
lines = append(lines, fmt.Sprintf("notification_level: %s", fields.NotificationLevel))
|
|
} else {
|
|
lines = append(lines, "notification_level: 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
|
|
case "cleanup_status":
|
|
fields.CleanupStatus = value
|
|
case "active_mr":
|
|
fields.ActiveMR = value
|
|
case "notification_level":
|
|
fields.NotificationLevel = value
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
|
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
|
// Use AgentBeadID() helper to generate correct IDs.
|
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
|
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
|
description := FormatAgentDescription(title, fields)
|
|
|
|
args := []string{"create", "--json",
|
|
"--id=" + id,
|
|
"--title=" + title,
|
|
"--description=" + description,
|
|
"--labels=gt:agent",
|
|
}
|
|
|
|
// Default actor from BD_ACTOR env var for provenance tracking
|
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
|
args = append(args, "--actor="+actor)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Set the role slot if specified (this is the authoritative storage)
|
|
if fields != nil && fields.RoleBead != "" {
|
|
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
|
// Non-fatal: warn but continue
|
|
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Set the hook slot if specified (this is the authoritative storage)
|
|
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
|
// agent's hook slot is empty. See mi-619.
|
|
if fields != nil && fields.HookBead != "" {
|
|
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
|
// Non-fatal: warn but continue - description text has the backup
|
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
|
}
|
|
}
|
|
|
|
return &issue, nil
|
|
}
|
|
|
|
// UpdateAgentState updates the agent_state field in an agent bead.
|
|
// Optionally updates hook_bead if provided.
|
|
//
|
|
// IMPORTANT: This function uses the proper bd commands to update agent fields:
|
|
// - `bd agent state` for agent_state (uses SQLite column directly)
|
|
// - `bd slot set/clear` for hook_bead (uses SQLite column directly)
|
|
//
|
|
// This ensures consistency with `bd slot show` and other beads commands.
|
|
// Previously, this function embedded these fields in the description text,
|
|
// which caused inconsistencies with bd slot commands (see GH #gt-9v52).
|
|
func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) error {
|
|
// Update agent state using bd agent state command
|
|
// This updates the agent_state column directly in SQLite
|
|
_, err := b.run("agent", "state", id, state)
|
|
if err != nil {
|
|
return fmt.Errorf("updating agent state: %w", err)
|
|
}
|
|
|
|
// Update hook_bead if provided
|
|
if hookBead != nil {
|
|
if *hookBead != "" {
|
|
// Set the hook using bd slot set
|
|
// This updates the hook_bead column directly in SQLite
|
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
|
if err != nil {
|
|
// If slot is already occupied, clear it first then retry
|
|
// This handles re-slinging scenarios where we're updating the hook
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "already occupied") {
|
|
_, _ = b.run("slot", "clear", id, "hook")
|
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("setting hook: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
// Clear the hook
|
|
_, err = b.run("slot", "clear", id, "hook")
|
|
if err != nil {
|
|
return fmt.Errorf("clearing hook: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetHookBead sets the hook_bead slot on an agent bead.
|
|
// This is a convenience wrapper that only sets the hook without changing agent_state.
|
|
// Per gt-zecmc: agent_state ("running", "dead", "idle") is observable from tmux
|
|
// and should not be recorded in beads ("discover, don't track" principle).
|
|
func (b *Beads) SetHookBead(agentBeadID, hookBeadID string) error {
|
|
// Set the hook using bd slot set
|
|
// This updates the hook_bead column directly in SQLite
|
|
_, err := b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
|
if err != nil {
|
|
// If slot is already occupied, clear it first then retry
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "already occupied") {
|
|
_, _ = b.run("slot", "clear", agentBeadID, "hook")
|
|
_, err = b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("setting hook: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearHookBead clears the hook_bead slot on an agent bead.
|
|
// Used when work is complete or unslung.
|
|
func (b *Beads) ClearHookBead(agentBeadID string) error {
|
|
_, err := b.run("slot", "clear", agentBeadID, "hook")
|
|
if err != nil {
|
|
return fmt.Errorf("clearing hook: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateAgentCleanupStatus updates the cleanup_status field in an agent bead.
|
|
// This is called by the polecat to self-report its git state (ZFC compliance).
|
|
// Valid statuses: clean, has_uncommitted, has_stash, has_unpushed
|
|
func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus 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.CleanupStatus = cleanupStatus
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// UpdateAgentActiveMR updates the active_mr field in an agent bead.
|
|
// This links the agent to their current merge request for traceability.
|
|
// Pass empty string to clear the field (e.g., after merge completes).
|
|
func (b *Beads) UpdateAgentActiveMR(id string, activeMR 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.ActiveMR = activeMR
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// UpdateAgentNotificationLevel updates the notification_level field in an agent bead.
|
|
// Valid levels: verbose, normal, muted (DND mode).
|
|
// Pass empty string to reset to default (normal).
|
|
func (b *Beads) UpdateAgentNotificationLevel(id string, level string) error {
|
|
// Validate level
|
|
if level != "" && level != NotifyVerbose && level != NotifyNormal && level != NotifyMuted {
|
|
return fmt.Errorf("invalid notification level %q: must be verbose, normal, or muted", level)
|
|
}
|
|
|
|
// 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.NotificationLevel = level
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// GetAgentNotificationLevel returns the notification level for an agent.
|
|
// Returns "normal" if not set (the default).
|
|
func (b *Beads) GetAgentNotificationLevel(id string) (string, error) {
|
|
_, fields, err := b.GetAgentBead(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if fields == nil {
|
|
return NotifyNormal, nil
|
|
}
|
|
if fields.NotificationLevel == "" {
|
|
return NotifyNormal, nil
|
|
}
|
|
return fields.NotificationLevel, nil
|
|
}
|
|
|
|
// 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 !HasLabel(issue, "gt:agent") {
|
|
return nil, nil, fmt.Errorf("issue %s is not an agent bead (missing gt:agent label)", id)
|
|
}
|
|
|
|
fields := ParseAgentFields(issue.Description)
|
|
return issue, fields, nil
|
|
}
|
|
|
|
// ListAgentBeads returns all agent beads in a single query.
|
|
// Returns a map of agent bead ID to Issue.
|
|
func (b *Beads) ListAgentBeads() (map[string]*Issue, error) {
|
|
out, err := b.run("list", "--label=gt:agent", "--json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var issues []*Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd list output: %w", err)
|
|
}
|
|
|
|
result := make(map[string]*Issue, len(issues))
|
|
for _, issue := range issues {
|
|
result[issue.ID] = issue
|
|
}
|
|
|
|
return result, nil
|
|
}
|