From dce8d8bfaea842147ab70f2c599ae7a80ac492f2 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 01:29:35 -0800 Subject: [PATCH] refactor: Split beads.go into focused files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - beads.go (512 lines): Core types and bd CLI wrapper operations - fields.go (327 lines): AttachmentFields and MRFields parsing/formatting - handoff.go (218 lines): Handoff bead ops, ClearMail, molecule attach/detach - audit.go (98 lines): Detach audit logging No functional changes - just reorganization for maintainability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/audit.go | 98 ++++++ internal/beads/beads.go | 624 -------------------------------------- internal/beads/fields.go | 327 ++++++++++++++++++++ internal/beads/handoff.go | 218 +++++++++++++ 4 files changed, 643 insertions(+), 624 deletions(-) create mode 100644 internal/beads/audit.go create mode 100644 internal/beads/fields.go create mode 100644 internal/beads/handoff.go diff --git a/internal/beads/audit.go b/internal/beads/audit.go new file mode 100644 index 00000000..b9bc5fa5 --- /dev/null +++ b/internal/beads/audit.go @@ -0,0 +1,98 @@ +// Package beads provides audit logging for molecule operations. +package beads + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// DetachAuditEntry represents an audit log entry for a detach operation. +type DetachAuditEntry struct { + Timestamp string `json:"timestamp"` + Operation string `json:"operation"` // "detach", "burn", "squash" + PinnedBeadID string `json:"pinned_bead_id"` + DetachedMolecule string `json:"detached_molecule"` + DetachedBy string `json:"detached_by,omitempty"` // Agent that triggered detach + Reason string `json:"reason,omitempty"` // Optional reason for detach + PreviousState string `json:"previous_state,omitempty"` +} + +// DetachOptions specifies optional context for a detach operation. +type DetachOptions struct { + Operation string // "detach", "burn", "squash" - defaults to "detach" + Agent string // Who is performing the detach + Reason string // Optional reason for the detach +} + +// DetachMoleculeWithAudit removes molecule attachment from a pinned bead and logs the operation. +// Returns the updated issue. +func (b *Beads) DetachMoleculeWithAudit(pinnedBeadID string, opts DetachOptions) (*Issue, error) { + // Fetch the pinned bead first to get previous state + issue, err := b.Show(pinnedBeadID) + if err != nil { + return nil, fmt.Errorf("fetching pinned bead: %w", err) + } + + // Get current attachment info for audit + attachment := ParseAttachmentFields(issue) + if attachment == nil { + return issue, nil // Nothing to detach + } + + // Log the detach operation + operation := opts.Operation + if operation == "" { + operation = "detach" + } + entry := DetachAuditEntry{ + Timestamp: currentTimestamp(), + Operation: operation, + PinnedBeadID: pinnedBeadID, + DetachedMolecule: attachment.AttachedMolecule, + DetachedBy: opts.Agent, + Reason: opts.Reason, + PreviousState: issue.Status, + } + if err := b.LogDetachAudit(entry); err != nil { + // Log error but don't fail the detach operation + fmt.Fprintf(os.Stderr, "Warning: failed to write audit log: %v\n", err) + } + + // 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) +} + +// LogDetachAudit appends an audit entry to the audit log file. +// The audit log is stored in .beads/audit.log as JSONL format. +func (b *Beads) LogDetachAudit(entry DetachAuditEntry) error { + auditPath := filepath.Join(b.workDir, ".beads", "audit.log") + + // Marshal entry to JSON + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshaling audit entry: %w", err) + } + + // Append to audit log file + f, err := os.OpenFile(auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("opening audit log: %w", err) + } + defer f.Close() + + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("writing audit entry: %w", err) + } + + return nil +} diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 30becb95..88d9e176 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "strings" - "time" ) // Common errors @@ -511,626 +510,3 @@ func (b *Beads) IsBeadsRepo() bool { _, err := b.run("list", "--limit=1") return err == nil || !errors.Is(err, ErrNotARepo) } - -// StatusPinned is the status for pinned beads that never get closed. -const StatusPinned = "pinned" - -// 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 - issue, err := b.Create(CreateOptions{ - Title: HandoffBeadTitle(role), - Type: "task", - 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", - Type: "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 -} - -// AttachmentFields holds the attachment info for pinned beads. -// These fields track which molecule is attached to a handoff/pinned bead. -type AttachmentFields struct { - AttachedMolecule string // Root issue ID of the attached molecule - AttachedAt string // ISO 8601 timestamp when attached - AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode) -} - -// ParseAttachmentFields extracts attachment fields from an issue's description. -// Fields are expected as "key: value" lines. Returns nil if no attachment fields found. -func ParseAttachmentFields(issue *Issue) *AttachmentFields { - if issue == nil || issue.Description == "" { - return nil - } - - fields := &AttachmentFields{} - hasFields := false - - for _, line := range strings.Split(issue.Description, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // Look for "key: value" pattern - colonIdx := strings.Index(line, ":") - if colonIdx == -1 { - continue - } - - key := strings.TrimSpace(line[:colonIdx]) - value := strings.TrimSpace(line[colonIdx+1:]) - if value == "" { - continue - } - - // Map keys to fields (case-insensitive) - switch strings.ToLower(key) { - case "attached_molecule", "attached-molecule", "attachedmolecule": - fields.AttachedMolecule = value - hasFields = true - case "attached_at", "attached-at", "attachedat": - fields.AttachedAt = value - hasFields = true - case "attached_args", "attached-args", "attachedargs": - fields.AttachedArgs = value - hasFields = true - } - } - - if !hasFields { - return nil - } - return fields -} - -// FormatAttachmentFields formats AttachmentFields as a string suitable for an issue description. -// Only non-empty fields are included. -func FormatAttachmentFields(fields *AttachmentFields) string { - if fields == nil { - return "" - } - - var lines []string - - if fields.AttachedMolecule != "" { - lines = append(lines, "attached_molecule: "+fields.AttachedMolecule) - } - if fields.AttachedAt != "" { - lines = append(lines, "attached_at: "+fields.AttachedAt) - } - if fields.AttachedArgs != "" { - lines = append(lines, "attached_args: "+fields.AttachedArgs) - } - - return strings.Join(lines, "\n") -} - -// SetAttachmentFields updates an issue's description with the given attachment fields. -// Existing attachment field lines are replaced; other content is preserved. -// Returns the new description string. -func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string { - // Known attachment field keys (lowercase) - attachmentKeys := map[string]bool{ - "attached_molecule": true, - "attached-molecule": true, - "attachedmolecule": true, - "attached_at": true, - "attached-at": true, - "attachedat": true, - "attached_args": true, - "attached-args": true, - "attachedargs": true, - } - - // Collect non-attachment lines from existing description - var otherLines []string - if issue != nil && issue.Description != "" { - for _, line := range strings.Split(issue.Description, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - // Preserve blank lines in content - otherLines = append(otherLines, line) - continue - } - - // Check if this is an attachment field line - colonIdx := strings.Index(trimmed, ":") - if colonIdx == -1 { - otherLines = append(otherLines, line) - continue - } - - key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx])) - if !attachmentKeys[key] { - otherLines = append(otherLines, line) - } - // Skip attachment field lines - they'll be replaced - } - } - - // Build new description: attachment fields first, then other content - formatted := FormatAttachmentFields(fields) - - // Trim trailing blank lines from other content - for len(otherLines) > 0 && strings.TrimSpace(otherLines[len(otherLines)-1]) == "" { - otherLines = otherLines[:len(otherLines)-1] - } - // Trim leading blank lines from other content - for len(otherLines) > 0 && strings.TrimSpace(otherLines[0]) == "" { - otherLines = otherLines[1:] - } - - if formatted == "" { - return strings.Join(otherLines, "\n") - } - if len(otherLines) == 0 { - return formatted - } - - return formatted + "\n\n" + strings.Join(otherLines, "\n") -} - -// MRFields holds the structured fields for a merge-request issue. -// These fields are stored as key: value lines in the issue description. -type MRFields struct { - Branch string // Source branch name (e.g., "polecat/Nux/gt-xyz") - Target string // Target branch (e.g., "main" or "integration/gt-epic") - SourceIssue string // The work item being merged (e.g., "gt-xyz") - Worker string // Who did the work - Rig string // Which rig - MergeCommit string // SHA of merge commit (set on close) - CloseReason string // Reason for closing: merged, rejected, conflict, superseded -} - -// ParseMRFields extracts structured merge-request fields from an issue's description. -// Fields are expected as "key: value" lines, with optional prose text mixed in. -// Returns nil if no MR fields are found. -func ParseMRFields(issue *Issue) *MRFields { - if issue == nil || issue.Description == "" { - return nil - } - - fields := &MRFields{} - hasFields := false - - for _, line := range strings.Split(issue.Description, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // Look for "key: value" pattern - colonIdx := strings.Index(line, ":") - if colonIdx == -1 { - continue - } - - key := strings.TrimSpace(line[:colonIdx]) - value := strings.TrimSpace(line[colonIdx+1:]) - if value == "" { - continue - } - - // Map keys to fields (case-insensitive) - switch strings.ToLower(key) { - case "branch": - fields.Branch = value - hasFields = true - case "target": - fields.Target = value - hasFields = true - case "source_issue", "source-issue", "sourceissue": - fields.SourceIssue = value - hasFields = true - case "worker": - fields.Worker = value - hasFields = true - case "rig": - fields.Rig = value - hasFields = true - case "merge_commit", "merge-commit", "mergecommit": - fields.MergeCommit = value - hasFields = true - case "close_reason", "close-reason", "closereason": - fields.CloseReason = value - hasFields = true - } - } - - if !hasFields { - return nil - } - return fields -} - -// FormatMRFields formats MRFields as a string suitable for an issue description. -// Only non-empty fields are included. -func FormatMRFields(fields *MRFields) string { - if fields == nil { - return "" - } - - var lines []string - - if fields.Branch != "" { - lines = append(lines, "branch: "+fields.Branch) - } - if fields.Target != "" { - lines = append(lines, "target: "+fields.Target) - } - if fields.SourceIssue != "" { - lines = append(lines, "source_issue: "+fields.SourceIssue) - } - if fields.Worker != "" { - lines = append(lines, "worker: "+fields.Worker) - } - if fields.Rig != "" { - lines = append(lines, "rig: "+fields.Rig) - } - if fields.MergeCommit != "" { - lines = append(lines, "merge_commit: "+fields.MergeCommit) - } - if fields.CloseReason != "" { - lines = append(lines, "close_reason: "+fields.CloseReason) - } - - return strings.Join(lines, "\n") -} - -// SetMRFields updates an issue's description with the given MR fields. -// Existing MR field lines are replaced; other content is preserved. -// Returns the new description string. -func SetMRFields(issue *Issue, fields *MRFields) string { - if issue == nil { - return FormatMRFields(fields) - } - - // Known MR field keys (lowercase) - mrKeys := map[string]bool{ - "branch": true, - "target": true, - "source_issue": true, - "source-issue": true, - "sourceissue": true, - "worker": true, - "rig": true, - "merge_commit": true, - "merge-commit": true, - "mergecommit": true, - "close_reason": true, - "close-reason": true, - "closereason": true, - } - - // Collect non-MR lines from existing description - var otherLines []string - if issue.Description != "" { - for _, line := range strings.Split(issue.Description, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - // Preserve blank lines in content - otherLines = append(otherLines, line) - continue - } - - // Check if this is an MR field line - colonIdx := strings.Index(trimmed, ":") - if colonIdx == -1 { - otherLines = append(otherLines, line) - continue - } - - key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx])) - if !mrKeys[key] { - otherLines = append(otherLines, line) - } - // Skip MR field lines - they'll be replaced - } - } - - // Build new description: MR fields first, then other content - formatted := FormatMRFields(fields) - - // Trim trailing blank lines from other content - for len(otherLines) > 0 && strings.TrimSpace(otherLines[len(otherLines)-1]) == "" { - otherLines = otherLines[:len(otherLines)-1] - } - // Trim leading blank lines from other content - for len(otherLines) > 0 && strings.TrimSpace(otherLines[0]) == "" { - otherLines = otherLines[1:] - } - - if formatted == "" { - return strings.Join(otherLines, "\n") - } - if len(otherLines) == 0 { - return formatted - } - - return formatted + "\n\n" + strings.Join(otherLines, "\n") -} - -// 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) -} - -// DetachAuditEntry represents an audit log entry for a detach operation. -type DetachAuditEntry struct { - Timestamp string `json:"timestamp"` - Operation string `json:"operation"` // "detach", "burn", "squash" - PinnedBeadID string `json:"pinned_bead_id"` - DetachedMolecule string `json:"detached_molecule"` - DetachedBy string `json:"detached_by,omitempty"` // Agent that triggered detach - Reason string `json:"reason,omitempty"` // Optional reason for detach - PreviousState string `json:"previous_state,omitempty"` -} - -// DetachOptions specifies optional context for a detach operation. -type DetachOptions struct { - Operation string // "detach", "burn", "squash" - defaults to "detach" - Agent string // Who is performing the detach - Reason string // Optional reason for the detach -} - -// DetachMoleculeWithAudit removes molecule attachment from a pinned bead and logs the operation. -// Returns the updated issue. -func (b *Beads) DetachMoleculeWithAudit(pinnedBeadID string, opts DetachOptions) (*Issue, error) { - // Fetch the pinned bead first to get previous state - issue, err := b.Show(pinnedBeadID) - if err != nil { - return nil, fmt.Errorf("fetching pinned bead: %w", err) - } - - // Get current attachment info for audit - attachment := ParseAttachmentFields(issue) - if attachment == nil { - return issue, nil // Nothing to detach - } - - // Log the detach operation - operation := opts.Operation - if operation == "" { - operation = "detach" - } - entry := DetachAuditEntry{ - Timestamp: currentTimestamp(), - Operation: operation, - PinnedBeadID: pinnedBeadID, - DetachedMolecule: attachment.AttachedMolecule, - DetachedBy: opts.Agent, - Reason: opts.Reason, - PreviousState: issue.Status, - } - if err := b.LogDetachAudit(entry); err != nil { - // Log error but don't fail the detach operation - fmt.Fprintf(os.Stderr, "Warning: failed to write audit log: %v\n", err) - } - - // 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) -} - -// LogDetachAudit appends an audit entry to the audit log file. -// The audit log is stored in .beads/audit.log as JSONL format. -func (b *Beads) LogDetachAudit(entry DetachAuditEntry) error { - auditPath := filepath.Join(b.workDir, ".beads", "audit.log") - - // Marshal entry to JSON - data, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("marshaling audit entry: %w", err) - } - - // Append to audit log file - f, err := os.OpenFile(auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("opening audit log: %w", err) - } - defer f.Close() - - if _, err := f.Write(append(data, '\n')); err != nil { - return fmt.Errorf("writing audit entry: %w", err) - } - - return nil -} diff --git a/internal/beads/fields.go b/internal/beads/fields.go new file mode 100644 index 00000000..45258d5a --- /dev/null +++ b/internal/beads/fields.go @@ -0,0 +1,327 @@ +// Package beads provides field parsing utilities for structured issue descriptions. +package beads + +import "strings" + +// AttachmentFields holds the attachment info for pinned beads. +// These fields track which molecule is attached to a handoff/pinned bead. +type AttachmentFields struct { + AttachedMolecule string // Root issue ID of the attached molecule + AttachedAt string // ISO 8601 timestamp when attached + AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode) +} + +// ParseAttachmentFields extracts attachment fields from an issue's description. +// Fields are expected as "key: value" lines. Returns nil if no attachment fields found. +func ParseAttachmentFields(issue *Issue) *AttachmentFields { + if issue == nil || issue.Description == "" { + return nil + } + + fields := &AttachmentFields{} + hasFields := false + + for _, line := range strings.Split(issue.Description, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Look for "key: value" pattern + colonIdx := strings.Index(line, ":") + if colonIdx == -1 { + continue + } + + key := strings.TrimSpace(line[:colonIdx]) + value := strings.TrimSpace(line[colonIdx+1:]) + if value == "" { + continue + } + + // Map keys to fields (case-insensitive) + switch strings.ToLower(key) { + case "attached_molecule", "attached-molecule", "attachedmolecule": + fields.AttachedMolecule = value + hasFields = true + case "attached_at", "attached-at", "attachedat": + fields.AttachedAt = value + hasFields = true + case "attached_args", "attached-args", "attachedargs": + fields.AttachedArgs = value + hasFields = true + } + } + + if !hasFields { + return nil + } + return fields +} + +// FormatAttachmentFields formats AttachmentFields as a string suitable for an issue description. +// Only non-empty fields are included. +func FormatAttachmentFields(fields *AttachmentFields) string { + if fields == nil { + return "" + } + + var lines []string + + if fields.AttachedMolecule != "" { + lines = append(lines, "attached_molecule: "+fields.AttachedMolecule) + } + if fields.AttachedAt != "" { + lines = append(lines, "attached_at: "+fields.AttachedAt) + } + if fields.AttachedArgs != "" { + lines = append(lines, "attached_args: "+fields.AttachedArgs) + } + + return strings.Join(lines, "\n") +} + +// SetAttachmentFields updates an issue's description with the given attachment fields. +// Existing attachment field lines are replaced; other content is preserved. +// Returns the new description string. +func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string { + // Known attachment field keys (lowercase) + attachmentKeys := map[string]bool{ + "attached_molecule": true, + "attached-molecule": true, + "attachedmolecule": true, + "attached_at": true, + "attached-at": true, + "attachedat": true, + "attached_args": true, + "attached-args": true, + "attachedargs": true, + } + + // Collect non-attachment lines from existing description + var otherLines []string + if issue != nil && issue.Description != "" { + for _, line := range strings.Split(issue.Description, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + // Preserve blank lines in content + otherLines = append(otherLines, line) + continue + } + + // Check if this is an attachment field line + colonIdx := strings.Index(trimmed, ":") + if colonIdx == -1 { + otherLines = append(otherLines, line) + continue + } + + key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx])) + if !attachmentKeys[key] { + otherLines = append(otherLines, line) + } + // Skip attachment field lines - they'll be replaced + } + } + + // Build new description: attachment fields first, then other content + formatted := FormatAttachmentFields(fields) + + // Trim trailing blank lines from other content + for len(otherLines) > 0 && strings.TrimSpace(otherLines[len(otherLines)-1]) == "" { + otherLines = otherLines[:len(otherLines)-1] + } + // Trim leading blank lines from other content + for len(otherLines) > 0 && strings.TrimSpace(otherLines[0]) == "" { + otherLines = otherLines[1:] + } + + if formatted == "" { + return strings.Join(otherLines, "\n") + } + if len(otherLines) == 0 { + return formatted + } + + return formatted + "\n\n" + strings.Join(otherLines, "\n") +} + +// MRFields holds the structured fields for a merge-request issue. +// These fields are stored as key: value lines in the issue description. +type MRFields struct { + Branch string // Source branch name (e.g., "polecat/Nux/gt-xyz") + Target string // Target branch (e.g., "main" or "integration/gt-epic") + SourceIssue string // The work item being merged (e.g., "gt-xyz") + Worker string // Who did the work + Rig string // Which rig + MergeCommit string // SHA of merge commit (set on close) + CloseReason string // Reason for closing: merged, rejected, conflict, superseded +} + +// ParseMRFields extracts structured merge-request fields from an issue's description. +// Fields are expected as "key: value" lines, with optional prose text mixed in. +// Returns nil if no MR fields are found. +func ParseMRFields(issue *Issue) *MRFields { + if issue == nil || issue.Description == "" { + return nil + } + + fields := &MRFields{} + hasFields := false + + for _, line := range strings.Split(issue.Description, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Look for "key: value" pattern + colonIdx := strings.Index(line, ":") + if colonIdx == -1 { + continue + } + + key := strings.TrimSpace(line[:colonIdx]) + value := strings.TrimSpace(line[colonIdx+1:]) + if value == "" { + continue + } + + // Map keys to fields (case-insensitive) + switch strings.ToLower(key) { + case "branch": + fields.Branch = value + hasFields = true + case "target": + fields.Target = value + hasFields = true + case "source_issue", "source-issue", "sourceissue": + fields.SourceIssue = value + hasFields = true + case "worker": + fields.Worker = value + hasFields = true + case "rig": + fields.Rig = value + hasFields = true + case "merge_commit", "merge-commit", "mergecommit": + fields.MergeCommit = value + hasFields = true + case "close_reason", "close-reason", "closereason": + fields.CloseReason = value + hasFields = true + } + } + + if !hasFields { + return nil + } + return fields +} + +// FormatMRFields formats MRFields as a string suitable for an issue description. +// Only non-empty fields are included. +func FormatMRFields(fields *MRFields) string { + if fields == nil { + return "" + } + + var lines []string + + if fields.Branch != "" { + lines = append(lines, "branch: "+fields.Branch) + } + if fields.Target != "" { + lines = append(lines, "target: "+fields.Target) + } + if fields.SourceIssue != "" { + lines = append(lines, "source_issue: "+fields.SourceIssue) + } + if fields.Worker != "" { + lines = append(lines, "worker: "+fields.Worker) + } + if fields.Rig != "" { + lines = append(lines, "rig: "+fields.Rig) + } + if fields.MergeCommit != "" { + lines = append(lines, "merge_commit: "+fields.MergeCommit) + } + if fields.CloseReason != "" { + lines = append(lines, "close_reason: "+fields.CloseReason) + } + + return strings.Join(lines, "\n") +} + +// SetMRFields updates an issue's description with the given MR fields. +// Existing MR field lines are replaced; other content is preserved. +// Returns the new description string. +func SetMRFields(issue *Issue, fields *MRFields) string { + if issue == nil { + return FormatMRFields(fields) + } + + // Known MR field keys (lowercase) + mrKeys := map[string]bool{ + "branch": true, + "target": true, + "source_issue": true, + "source-issue": true, + "sourceissue": true, + "worker": true, + "rig": true, + "merge_commit": true, + "merge-commit": true, + "mergecommit": true, + "close_reason": true, + "close-reason": true, + "closereason": true, + } + + // Collect non-MR lines from existing description + var otherLines []string + if issue.Description != "" { + for _, line := range strings.Split(issue.Description, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + // Preserve blank lines in content + otherLines = append(otherLines, line) + continue + } + + // Check if this is an MR field line + colonIdx := strings.Index(trimmed, ":") + if colonIdx == -1 { + otherLines = append(otherLines, line) + continue + } + + key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx])) + if !mrKeys[key] { + otherLines = append(otherLines, line) + } + // Skip MR field lines - they'll be replaced + } + } + + // Build new description: MR fields first, then other content + formatted := FormatMRFields(fields) + + // Trim trailing blank lines from other content + for len(otherLines) > 0 && strings.TrimSpace(otherLines[len(otherLines)-1]) == "" { + otherLines = otherLines[:len(otherLines)-1] + } + // Trim leading blank lines from other content + for len(otherLines) > 0 && strings.TrimSpace(otherLines[0]) == "" { + otherLines = otherLines[1:] + } + + if formatted == "" { + return strings.Join(otherLines, "\n") + } + if len(otherLines) == 0 { + return formatted + } + + return formatted + "\n\n" + strings.Join(otherLines, "\n") +} diff --git a/internal/beads/handoff.go b/internal/beads/handoff.go new file mode 100644 index 00000000..c941f205 --- /dev/null +++ b/internal/beads/handoff.go @@ -0,0 +1,218 @@ +// 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. +const StatusPinned = "pinned" + +// 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 + issue, err := b.Create(CreateOptions{ + Title: HandoffBeadTitle(role), + Type: "task", + 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", + Type: "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) +}