Files
gastown/internal/beads/handoff.go
nux 9697007182 feat(beads): migrate from types to labels for bead classification
Updates the beads package to use label-based filtering (gt:agent, gt:role,
gt:merge-request, etc.) instead of the deprecated --type= flag.

Key changes:
- ListAgentBeads(): --type=agent -> --label=gt:agent
- CreateAgentBead/CreateDogAgentBead: add gt:agent label on creation
- ReadyWithType(): --type -> --label=gt:<type>
- GetRoleConfig()/GetAgentBead(): type check -> label check via HasLabel()
- FindMRForBranch(): Type filter -> Label filter
- Create()/CreateWithID(): convert Type to gt:<type> label
- CreateRigBead(): --type=rig -> --add-label=gt:rig
- ClearMail(): Type filter -> Label filter
- Add Label field to ListOptions (Type field deprecated)

Closes: gt-x0i2m

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:12:36 -08:00

224 lines
6.2 KiB
Go

// Package beads provides handoff bead operations for agent workflow management.
package beads
import (
"fmt"
"time"
)
// StatusPinned is the status for pinned beads that never get closed.
// These are "domain table" beads like role definitions that persist permanently.
const StatusPinned = "pinned"
// StatusHooked is the status for beads on an agent's hook (work assignment).
// This is distinct from pinned - hooked beads are active work, not permanent records.
const StatusHooked = "hooked"
// HandoffBeadTitle returns the well-known title for a role's handoff bead.
func HandoffBeadTitle(role string) string {
return role + " Handoff"
}
// FindHandoffBead finds the pinned handoff bead for a role by title.
// Returns nil if not found (not an error).
func (b *Beads) FindHandoffBead(role string) (*Issue, error) {
issues, err := b.List(ListOptions{Status: StatusPinned, Priority: -1})
if err != nil {
return nil, fmt.Errorf("listing pinned issues: %w", err)
}
targetTitle := HandoffBeadTitle(role)
for _, issue := range issues {
if issue.Title == targetTitle {
return issue, nil
}
}
return nil, nil
}
// GetOrCreateHandoffBead returns the handoff bead for a role, creating it if needed.
func (b *Beads) GetOrCreateHandoffBead(role string) (*Issue, error) {
// Check if it exists
existing, err := b.FindHandoffBead(role)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
// Create new handoff bead (type is deprecated, uses gt:task label via backward compat)
issue, err := b.Create(CreateOptions{
Title: HandoffBeadTitle(role),
Type: "task", // Converted to gt:task label by Create()
Priority: 2,
Description: "", // Empty until first handoff
Actor: role,
})
if err != nil {
return nil, fmt.Errorf("creating handoff bead: %w", err)
}
// Update to pinned status
status := StatusPinned
if err := b.Update(issue.ID, UpdateOptions{Status: &status}); err != nil {
return nil, fmt.Errorf("setting handoff bead to pinned: %w", err)
}
// Re-fetch to get updated status
return b.Show(issue.ID)
}
// UpdateHandoffContent updates the handoff bead's description with new content.
func (b *Beads) UpdateHandoffContent(role, content string) error {
issue, err := b.GetOrCreateHandoffBead(role)
if err != nil {
return err
}
return b.Update(issue.ID, UpdateOptions{Description: &content})
}
// ClearHandoffContent clears the handoff bead's description.
func (b *Beads) ClearHandoffContent(role string) error {
issue, err := b.FindHandoffBead(role)
if err != nil {
return err
}
if issue == nil {
return nil // Nothing to clear
}
empty := ""
return b.Update(issue.ID, UpdateOptions{Description: &empty})
}
// ClearMailResult contains statistics from a ClearMail operation.
type ClearMailResult struct {
Closed int // Number of messages closed
Cleared int // Number of pinned messages cleared (content removed)
}
// ClearMail closes or clears all open messages.
// Non-pinned messages are closed with the given reason.
// Pinned messages have their description cleared but remain open.
func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) {
// List all open messages
issues, err := b.List(ListOptions{
Status: "open",
Label: "gt:message",
Priority: -1,
})
if err != nil {
return nil, fmt.Errorf("listing messages: %w", err)
}
result := &ClearMailResult{}
// Separate pinned from non-pinned
var toClose []string
var toClear []*Issue
for _, issue := range issues {
if issue.Status == StatusPinned {
toClear = append(toClear, issue)
} else {
toClose = append(toClose, issue.ID)
}
}
// Close non-pinned messages in batch
if len(toClose) > 0 {
if err := b.CloseWithReason(reason, toClose...); err != nil {
return nil, fmt.Errorf("closing messages: %w", err)
}
result.Closed = len(toClose)
}
// Clear pinned messages
empty := ""
for _, issue := range toClear {
if err := b.Update(issue.ID, UpdateOptions{Description: &empty}); err != nil {
return nil, fmt.Errorf("clearing pinned message %s: %w", issue.ID, err)
}
result.Cleared++
}
return result, nil
}
// AttachMolecule attaches a molecule to a pinned bead by updating its description.
// The moleculeID is the root issue ID of the molecule to attach.
// Returns the updated issue.
func (b *Beads) AttachMolecule(pinnedBeadID, moleculeID string) (*Issue, error) {
// Fetch the pinned bead
issue, err := b.Show(pinnedBeadID)
if err != nil {
return nil, fmt.Errorf("fetching pinned bead: %w", err)
}
if issue.Status != StatusPinned {
return nil, fmt.Errorf("issue %s is not pinned (status: %s)", pinnedBeadID, issue.Status)
}
// Build attachment fields with current timestamp
fields := &AttachmentFields{
AttachedMolecule: moleculeID,
AttachedAt: currentTimestamp(),
}
// Update description with attachment fields
newDesc := SetAttachmentFields(issue, fields)
// Update the issue
if err := b.Update(pinnedBeadID, UpdateOptions{Description: &newDesc}); err != nil {
return nil, fmt.Errorf("updating pinned bead: %w", err)
}
// Re-fetch to return updated state
return b.Show(pinnedBeadID)
}
// DetachMolecule removes molecule attachment from a pinned bead.
// Returns the updated issue.
func (b *Beads) DetachMolecule(pinnedBeadID string) (*Issue, error) {
// Fetch the pinned bead
issue, err := b.Show(pinnedBeadID)
if err != nil {
return nil, fmt.Errorf("fetching pinned bead: %w", err)
}
// Check if there's anything to detach
if ParseAttachmentFields(issue) == nil {
return issue, nil // Nothing to detach
}
// Clear attachment fields by passing nil
newDesc := SetAttachmentFields(issue, nil)
// Update the issue
if err := b.Update(pinnedBeadID, UpdateOptions{Description: &newDesc}); err != nil {
return nil, fmt.Errorf("updating pinned bead: %w", err)
}
// Re-fetch to return updated state
return b.Show(pinnedBeadID)
}
// GetAttachment returns the attachment fields from a pinned bead.
// Returns nil if no molecule is attached.
func (b *Beads) GetAttachment(pinnedBeadID string) (*AttachmentFields, error) {
issue, err := b.Show(pinnedBeadID)
if err != nil {
return nil, err
}
return ParseAttachmentFields(issue), nil
}
// currentTimestamp returns the current time in ISO 8601 format.
func currentTimestamp() string {
return time.Now().UTC().Format(time.RFC3339)
}