Agent session molecules (gt-gastown-crew-joe, gt-gastown-witness, etc.) update frequently for status tracking, creating noisy entries in the activity feed. This change: - Adds IsAgentSessionBead() to identify agent session beads - Filters out "update" events for agent sessions from the event feed - Still updates the agent tree so status is visible there - Still shows create/complete/fail/delete events for agents The filtering happens in addEvent() in the TUI feed model. Agent session updates are identified by parsing the bead ID pattern and checking for known agent roles (mayor, deacon, witness, refinery, crew, polecat). Resolves: gt-sb6m4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
842 lines
24 KiB
Go
842 lines
24 KiB
Go
// Package beads provides a wrapper for the bd (beads) CLI.
|
|
package beads
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Common errors
|
|
var (
|
|
ErrNotInstalled = errors.New("bd not installed: run 'pip install beads-cli' or see https://github.com/anthropics/beads")
|
|
ErrNotARepo = errors.New("not a beads repository (no .beads directory found)")
|
|
ErrSyncConflict = errors.New("beads sync conflict")
|
|
ErrNotFound = errors.New("issue not found")
|
|
)
|
|
|
|
// ResolveBeadsDir returns the actual beads directory, following any redirect.
|
|
// If workDir/.beads/redirect exists, it reads the redirect path and resolves it
|
|
// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads.
|
|
//
|
|
// This is essential for crew workers and polecats that use shared beads via redirect.
|
|
// The redirect file contains a relative path like "../../mayor/rig/.beads".
|
|
//
|
|
// Example: if we're at crew/max/ and .beads/redirect contains "../../mayor/rig/.beads",
|
|
// the redirect is resolved from crew/max/ (not crew/max/.beads/), giving us
|
|
// mayor/rig/.beads at the rig root level.
|
|
func ResolveBeadsDir(workDir string) string {
|
|
beadsDir := filepath.Join(workDir, ".beads")
|
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
|
|
|
// Check for redirect file
|
|
data, err := os.ReadFile(redirectPath)
|
|
if err != nil {
|
|
// No redirect, use local .beads
|
|
return beadsDir
|
|
}
|
|
|
|
// Read and clean the redirect path
|
|
redirectTarget := strings.TrimSpace(string(data))
|
|
if redirectTarget == "" {
|
|
return beadsDir
|
|
}
|
|
|
|
// Resolve relative to workDir (the redirect is written from the perspective
|
|
// of being inside workDir, not inside workDir/.beads)
|
|
// e.g., redirect contains "../../mayor/rig/.beads"
|
|
// from crew/max/, this resolves to mayor/rig/.beads
|
|
resolved := filepath.Join(workDir, redirectTarget)
|
|
|
|
// Clean the path to resolve .. components
|
|
resolved = filepath.Clean(resolved)
|
|
|
|
return resolved
|
|
}
|
|
|
|
// Issue represents a beads issue.
|
|
type Issue struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
Priority int `json:"priority"`
|
|
Type string `json:"issue_type"`
|
|
CreatedAt string `json:"created_at"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
ClosedAt string `json:"closed_at,omitempty"`
|
|
Parent string `json:"parent,omitempty"`
|
|
Assignee string `json:"assignee,omitempty"`
|
|
Children []string `json:"children,omitempty"`
|
|
DependsOn []string `json:"depends_on,omitempty"`
|
|
Blocks []string `json:"blocks,omitempty"`
|
|
BlockedBy []string `json:"blocked_by,omitempty"`
|
|
|
|
// Agent bead slots (type=agent only)
|
|
HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook
|
|
RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared)
|
|
|
|
// Counts from list output
|
|
DependencyCount int `json:"dependency_count,omitempty"`
|
|
DependentCount int `json:"dependent_count,omitempty"`
|
|
BlockedByCount int `json:"blocked_by_count,omitempty"`
|
|
|
|
// Detailed dependency info from show output
|
|
Dependencies []IssueDep `json:"dependencies,omitempty"`
|
|
Dependents []IssueDep `json:"dependents,omitempty"`
|
|
}
|
|
|
|
// IssueDep represents a dependency or dependent issue with its relation.
|
|
type IssueDep struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Priority int `json:"priority"`
|
|
Type string `json:"issue_type"`
|
|
DependencyType string `json:"dependency_type,omitempty"`
|
|
}
|
|
|
|
// ListOptions specifies filters for listing issues.
|
|
type ListOptions struct {
|
|
Status string // "open", "closed", "all"
|
|
Type string // "task", "bug", "feature", "epic"
|
|
Priority int // 0-4, -1 for no filter
|
|
Parent string // filter by parent ID
|
|
Assignee string // filter by assignee (e.g., "gastown/Toast")
|
|
NoAssignee bool // filter for issues with no assignee
|
|
}
|
|
|
|
// CreateOptions specifies options for creating an issue.
|
|
type CreateOptions struct {
|
|
Title string
|
|
Type string // "task", "bug", "feature", "epic"
|
|
Priority int // 0-4
|
|
Description string
|
|
Parent string
|
|
Actor string // Who is creating this issue (populates created_by)
|
|
}
|
|
|
|
// UpdateOptions specifies options for updating an issue.
|
|
type UpdateOptions struct {
|
|
Title *string
|
|
Status *string
|
|
Priority *int
|
|
Description *string
|
|
Assignee *string
|
|
AddLabels []string // Labels to add
|
|
RemoveLabels []string // Labels to remove
|
|
SetLabels []string // Labels to set (replaces all existing)
|
|
}
|
|
|
|
// SyncStatus represents the sync status of the beads repository.
|
|
type SyncStatus struct {
|
|
Branch string
|
|
Ahead int
|
|
Behind int
|
|
Conflicts []string
|
|
}
|
|
|
|
// Beads wraps bd CLI operations for a working directory.
|
|
type Beads struct {
|
|
workDir string
|
|
}
|
|
|
|
// New creates a new Beads wrapper for the given directory.
|
|
func New(workDir string) *Beads {
|
|
return &Beads{workDir: workDir}
|
|
}
|
|
|
|
// run executes a bd command and returns stdout.
|
|
func (b *Beads) run(args ...string) ([]byte, error) {
|
|
cmd := exec.Command("bd", args...)
|
|
cmd.Dir = b.workDir
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return nil, b.wrapError(err, stderr.String(), args)
|
|
}
|
|
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
// wrapError wraps bd errors with context.
|
|
func (b *Beads) wrapError(err error, stderr string, args []string) error {
|
|
stderr = strings.TrimSpace(stderr)
|
|
|
|
// Check for bd not installed
|
|
if execErr, ok := err.(*exec.Error); ok && errors.Is(execErr.Err, exec.ErrNotFound) {
|
|
return ErrNotInstalled
|
|
}
|
|
|
|
// Detect specific error types from stderr
|
|
if strings.Contains(stderr, "not a beads repository") ||
|
|
strings.Contains(stderr, "No .beads directory") ||
|
|
strings.Contains(stderr, ".beads") && strings.Contains(stderr, "not found") {
|
|
return ErrNotARepo
|
|
}
|
|
if strings.Contains(stderr, "sync conflict") || strings.Contains(stderr, "CONFLICT") {
|
|
return ErrSyncConflict
|
|
}
|
|
if strings.Contains(stderr, "not found") || strings.Contains(stderr, "Issue not found") {
|
|
return ErrNotFound
|
|
}
|
|
|
|
if stderr != "" {
|
|
return fmt.Errorf("bd %s: %s", strings.Join(args, " "), stderr)
|
|
}
|
|
return fmt.Errorf("bd %s: %w", strings.Join(args, " "), err)
|
|
}
|
|
|
|
// List returns issues matching the given options.
|
|
func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
|
|
args := []string{"list", "--json"}
|
|
|
|
if opts.Status != "" {
|
|
args = append(args, "--status="+opts.Status)
|
|
}
|
|
if opts.Type != "" {
|
|
args = append(args, "--type="+opts.Type)
|
|
}
|
|
if opts.Priority >= 0 {
|
|
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
|
|
}
|
|
if opts.Parent != "" {
|
|
args = append(args, "--parent="+opts.Parent)
|
|
}
|
|
if opts.Assignee != "" {
|
|
args = append(args, "--assignee="+opts.Assignee)
|
|
}
|
|
if opts.NoAssignee {
|
|
args = append(args, "--no-assignee")
|
|
}
|
|
|
|
out, err := b.run(args...)
|
|
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)
|
|
}
|
|
|
|
return issues, nil
|
|
}
|
|
|
|
// ListByAssignee returns all issues assigned to a specific assignee.
|
|
// The assignee is typically in the format "rig/polecatName" (e.g., "gastown/Toast").
|
|
func (b *Beads) ListByAssignee(assignee string) ([]*Issue, error) {
|
|
return b.List(ListOptions{
|
|
Status: "all", // Include both open and closed for state derivation
|
|
Assignee: assignee,
|
|
Priority: -1, // No priority filter
|
|
})
|
|
}
|
|
|
|
// GetAssignedIssue returns the first open issue assigned to the given assignee.
|
|
// Returns nil if no open issue is assigned.
|
|
func (b *Beads) GetAssignedIssue(assignee string) (*Issue, error) {
|
|
issues, err := b.List(ListOptions{
|
|
Status: "open",
|
|
Assignee: assignee,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Also check in_progress status explicitly
|
|
if len(issues) == 0 {
|
|
issues, err = b.List(ListOptions{
|
|
Status: "in_progress",
|
|
Assignee: assignee,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return issues[0], nil
|
|
}
|
|
|
|
// Ready returns issues that are ready to work (not blocked).
|
|
func (b *Beads) Ready() ([]*Issue, error) {
|
|
out, err := b.run("ready", "--json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var issues []*Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd ready output: %w", err)
|
|
}
|
|
|
|
return issues, nil
|
|
}
|
|
|
|
// ReadyWithType returns ready issues filtered by type.
|
|
// Uses bd ready --type flag for server-side filtering.
|
|
func (b *Beads) ReadyWithType(issueType string) ([]*Issue, error) {
|
|
out, err := b.run("ready", "--json", "--type", issueType, "-n", "100")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var issues []*Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd ready output: %w", err)
|
|
}
|
|
|
|
return issues, nil
|
|
}
|
|
|
|
// Show returns detailed information about an issue.
|
|
func (b *Beads) Show(id string) (*Issue, error) {
|
|
out, err := b.run("show", id, "--json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// bd show --json returns an array with one element
|
|
var issues []*Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd show output: %w", err)
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return issues[0], nil
|
|
}
|
|
|
|
// Blocked returns issues that are blocked by dependencies.
|
|
func (b *Beads) Blocked() ([]*Issue, error) {
|
|
out, err := b.run("blocked", "--json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var issues []*Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd blocked output: %w", err)
|
|
}
|
|
|
|
return issues, nil
|
|
}
|
|
|
|
// Create creates a new issue and returns it.
|
|
func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
|
|
args := []string{"create", "--json"}
|
|
|
|
if opts.Title != "" {
|
|
args = append(args, "--title="+opts.Title)
|
|
}
|
|
if opts.Type != "" {
|
|
args = append(args, "--type="+opts.Type)
|
|
}
|
|
if opts.Priority >= 0 {
|
|
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
|
|
}
|
|
if opts.Description != "" {
|
|
args = append(args, "--description="+opts.Description)
|
|
}
|
|
if opts.Parent != "" {
|
|
args = append(args, "--parent="+opts.Parent)
|
|
}
|
|
if opts.Actor != "" {
|
|
args = append(args, "--actor="+opts.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)
|
|
}
|
|
|
|
return &issue, nil
|
|
}
|
|
|
|
// Update updates an existing issue.
|
|
func (b *Beads) Update(id string, opts UpdateOptions) error {
|
|
args := []string{"update", id}
|
|
|
|
if opts.Title != nil {
|
|
args = append(args, "--title="+*opts.Title)
|
|
}
|
|
if opts.Status != nil {
|
|
args = append(args, "--status="+*opts.Status)
|
|
}
|
|
if opts.Priority != nil {
|
|
args = append(args, fmt.Sprintf("--priority=%d", *opts.Priority))
|
|
}
|
|
if opts.Description != nil {
|
|
args = append(args, "--description="+*opts.Description)
|
|
}
|
|
if opts.Assignee != nil {
|
|
args = append(args, "--assignee="+*opts.Assignee)
|
|
}
|
|
// Label operations: set-labels replaces all, otherwise use add/remove
|
|
if len(opts.SetLabels) > 0 {
|
|
for _, label := range opts.SetLabels {
|
|
args = append(args, "--set-labels="+label)
|
|
}
|
|
} else {
|
|
for _, label := range opts.AddLabels {
|
|
args = append(args, "--add-label="+label)
|
|
}
|
|
for _, label := range opts.RemoveLabels {
|
|
args = append(args, "--remove-label="+label)
|
|
}
|
|
}
|
|
|
|
_, err := b.run(args...)
|
|
return err
|
|
}
|
|
|
|
// Close closes one or more issues.
|
|
func (b *Beads) Close(ids ...string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
args := append([]string{"close"}, ids...)
|
|
_, err := b.run(args...)
|
|
return err
|
|
}
|
|
|
|
// CloseWithReason closes one or more issues with a reason.
|
|
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
args := append([]string{"close"}, ids...)
|
|
args = append(args, "--reason="+reason)
|
|
_, err := b.run(args...)
|
|
return err
|
|
}
|
|
|
|
// Release moves an in_progress issue back to open status.
|
|
// This is used to recover stuck steps when a worker dies mid-task.
|
|
// It clears the assignee so the step can be claimed by another worker.
|
|
func (b *Beads) Release(id string) error {
|
|
return b.ReleaseWithReason(id, "")
|
|
}
|
|
|
|
// ReleaseWithReason moves an in_progress issue back to open status with a reason.
|
|
// The reason is added as a note to the issue for tracking purposes.
|
|
func (b *Beads) ReleaseWithReason(id, reason string) error {
|
|
args := []string{"update", id, "--status=open", "--assignee="}
|
|
|
|
// Add reason as a note if provided
|
|
if reason != "" {
|
|
args = append(args, "--notes=Released: "+reason)
|
|
}
|
|
|
|
_, err := b.run(args...)
|
|
return err
|
|
}
|
|
|
|
// AddDependency adds a dependency: issue depends on dependsOn.
|
|
func (b *Beads) AddDependency(issue, dependsOn string) error {
|
|
_, err := b.run("dep", "add", issue, dependsOn)
|
|
return err
|
|
}
|
|
|
|
// RemoveDependency removes a dependency.
|
|
func (b *Beads) RemoveDependency(issue, dependsOn string) error {
|
|
_, err := b.run("dep", "remove", issue, dependsOn)
|
|
return err
|
|
}
|
|
|
|
// Sync syncs beads with remote.
|
|
func (b *Beads) Sync() error {
|
|
_, err := b.run("sync")
|
|
return err
|
|
}
|
|
|
|
// SyncFromMain syncs beads updates from main branch.
|
|
func (b *Beads) SyncFromMain() error {
|
|
_, err := b.run("sync", "--from-main")
|
|
return err
|
|
}
|
|
|
|
// SyncStatus returns the sync status without performing a sync.
|
|
func (b *Beads) SyncStatus() (*SyncStatus, error) {
|
|
out, err := b.run("sync", "--status", "--json")
|
|
if err != nil {
|
|
// If sync branch doesn't exist, return empty status
|
|
if strings.Contains(err.Error(), "does not exist") {
|
|
return &SyncStatus{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var status SyncStatus
|
|
if err := json.Unmarshal(out, &status); err != nil {
|
|
return nil, fmt.Errorf("parsing bd sync status output: %w", err)
|
|
}
|
|
|
|
return &status, nil
|
|
}
|
|
|
|
// Stats returns repository statistics.
|
|
func (b *Beads) Stats() (string, error) {
|
|
out, err := b.run("stats")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// IsBeadsRepo checks if the working directory is a beads repository.
|
|
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 (canonical location; may not exist yet)
|
|
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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.
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Agent bead ID naming convention:
|
|
// prefix-rig-role-name
|
|
//
|
|
// Examples:
|
|
// - gt-mayor (town-level, no rig)
|
|
// - gt-deacon (town-level, no rig)
|
|
// - gt-gastown-witness (rig-level singleton)
|
|
// - gt-gastown-refinery (rig-level singleton)
|
|
// - gt-gastown-crew-max (rig-level named agent)
|
|
// - gt-gastown-polecat-Toast (rig-level named agent)
|
|
|
|
// AgentBeadIDWithPrefix generates an agent bead ID using the specified prefix.
|
|
// The prefix should NOT include the hyphen (e.g., "gt", "bd", not "gt-", "bd-").
|
|
// For town-level agents (mayor, deacon), pass empty rig and name.
|
|
// For rig-level singletons (witness, refinery), pass empty name.
|
|
// For named agents (crew, polecat), pass all three.
|
|
func AgentBeadIDWithPrefix(prefix, rig, role, name string) string {
|
|
if rig == "" {
|
|
// Town-level agent: prefix-mayor, prefix-deacon
|
|
return prefix + "-" + role
|
|
}
|
|
if name == "" {
|
|
// Rig-level singleton: prefix-rig-witness, prefix-rig-refinery
|
|
return prefix + "-" + rig + "-" + role
|
|
}
|
|
// Rig-level named agent: prefix-rig-role-name
|
|
return prefix + "-" + rig + "-" + role + "-" + name
|
|
}
|
|
|
|
// AgentBeadID generates the canonical agent bead ID using "gt" prefix.
|
|
// For non-gastown rigs, use AgentBeadIDWithPrefix with the rig's configured prefix.
|
|
func AgentBeadID(rig, role, name string) string {
|
|
return AgentBeadIDWithPrefix("gt", rig, role, name)
|
|
}
|
|
|
|
// MayorBeadID returns the Mayor agent bead ID.
|
|
func MayorBeadID() string {
|
|
return "gt-mayor"
|
|
}
|
|
|
|
// DeaconBeadID returns the Deacon agent bead ID.
|
|
func DeaconBeadID() string {
|
|
return "gt-deacon"
|
|
}
|
|
|
|
// WitnessBeadIDWithPrefix returns the Witness agent bead ID for a rig using the specified prefix.
|
|
func WitnessBeadIDWithPrefix(prefix, rig string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "witness", "")
|
|
}
|
|
|
|
// WitnessBeadID returns the Witness agent bead ID for a rig using "gt" prefix.
|
|
func WitnessBeadID(rig string) string {
|
|
return WitnessBeadIDWithPrefix("gt", rig)
|
|
}
|
|
|
|
// RefineryBeadIDWithPrefix returns the Refinery agent bead ID for a rig using the specified prefix.
|
|
func RefineryBeadIDWithPrefix(prefix, rig string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "refinery", "")
|
|
}
|
|
|
|
// RefineryBeadID returns the Refinery agent bead ID for a rig using "gt" prefix.
|
|
func RefineryBeadID(rig string) string {
|
|
return RefineryBeadIDWithPrefix("gt", rig)
|
|
}
|
|
|
|
// CrewBeadIDWithPrefix returns a Crew worker agent bead ID using the specified prefix.
|
|
func CrewBeadIDWithPrefix(prefix, rig, name string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "crew", name)
|
|
}
|
|
|
|
// CrewBeadID returns a Crew worker agent bead ID using "gt" prefix.
|
|
func CrewBeadID(rig, name string) string {
|
|
return CrewBeadIDWithPrefix("gt", rig, name)
|
|
}
|
|
|
|
// PolecatBeadIDWithPrefix returns a Polecat agent bead ID using the specified prefix.
|
|
func PolecatBeadIDWithPrefix(prefix, rig, name string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "polecat", name)
|
|
}
|
|
|
|
// PolecatBeadID returns a Polecat agent bead ID using "gt" prefix.
|
|
func PolecatBeadID(rig, name string) string {
|
|
return PolecatBeadIDWithPrefix("gt", rig, name)
|
|
}
|
|
|
|
// ParseAgentBeadID parses an agent bead ID into its components.
|
|
// Returns rig, role, name, and whether parsing succeeded.
|
|
// For town-level agents, rig will be empty.
|
|
// For singletons, name will be empty.
|
|
func ParseAgentBeadID(id string) (rig, role, name string, ok bool) {
|
|
if !strings.HasPrefix(id, "gt-") {
|
|
return "", "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(id, "gt-")
|
|
parts := strings.Split(rest, "-")
|
|
|
|
switch len(parts) {
|
|
case 1:
|
|
// Town-level: gt-mayor, gt-deacon
|
|
return "", parts[0], "", true
|
|
case 2:
|
|
// Rig-level singleton: gt-gastown-witness
|
|
return parts[0], parts[1], "", true
|
|
case 3:
|
|
// Rig-level named: gt-gastown-crew-max
|
|
return parts[0], parts[1], parts[2], true
|
|
default:
|
|
// Handle names with hyphens: gt-gastown-polecat-my-agent-name
|
|
if len(parts) >= 3 {
|
|
return parts[0], parts[1], strings.Join(parts[2:], "-"), true
|
|
}
|
|
return "", "", "", false
|
|
}
|
|
}
|
|
|
|
// IsAgentSessionBead returns true if the bead ID represents an agent session molecule.
|
|
// Agent session beads follow patterns like gt-mayor, gt-gastown-witness, gt-gastown-crew-joe.
|
|
// These are used to track agent state and update frequently, which can create noise.
|
|
func IsAgentSessionBead(beadID string) bool {
|
|
_, role, _, ok := ParseAgentBeadID(beadID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
// Known agent roles
|
|
switch role {
|
|
case "mayor", "deacon", "witness", "refinery", "crew", "polecat":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|