diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 9fa6ff20..c1811e22 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -8,6 +8,7 @@ import ( "fmt" "os/exec" "strings" + "time" ) // Common errors @@ -601,6 +602,139 @@ func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) { 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 +} + +// 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 + } + } + + 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) + } + + 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, + } + + // 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 { @@ -780,3 +914,77 @@ func SetMRFields(issue *Issue, fields *MRFields) string { 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) +} diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index fa90077e..95f6f335 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -626,3 +626,258 @@ Also see http://localhost:8080/api`, t.Error("branch field was not set") } } + +// TestParseAttachmentFields tests parsing attachment fields from issue descriptions. +func TestParseAttachmentFields(t *testing.T) { + tests := []struct { + name string + issue *Issue + wantNil bool + wantFields *AttachmentFields + }{ + { + name: "nil issue", + issue: nil, + wantNil: true, + }, + { + name: "empty description", + issue: &Issue{Description: ""}, + wantNil: true, + }, + { + name: "no attachment fields", + issue: &Issue{Description: "This is just plain text\nwith no attachment markers"}, + wantNil: true, + }, + { + name: "both fields", + issue: &Issue{ + Description: `attached_molecule: mol-xyz +attached_at: 2025-12-21T15:30:00Z`, + }, + wantFields: &AttachmentFields{ + AttachedMolecule: "mol-xyz", + AttachedAt: "2025-12-21T15:30:00Z", + }, + }, + { + name: "only molecule", + issue: &Issue{ + Description: `attached_molecule: mol-abc`, + }, + wantFields: &AttachmentFields{ + AttachedMolecule: "mol-abc", + }, + }, + { + name: "mixed with other content", + issue: &Issue{ + Description: `attached_molecule: mol-def +attached_at: 2025-12-21T10:00:00Z + +This is a handoff bead for the polecat. +Keep working on the current task.`, + }, + wantFields: &AttachmentFields{ + AttachedMolecule: "mol-def", + AttachedAt: "2025-12-21T10:00:00Z", + }, + }, + { + name: "alternate key formats (hyphen)", + issue: &Issue{ + Description: `attached-molecule: mol-ghi +attached-at: 2025-12-21T12:00:00Z`, + }, + wantFields: &AttachmentFields{ + AttachedMolecule: "mol-ghi", + AttachedAt: "2025-12-21T12:00:00Z", + }, + }, + { + name: "case insensitive", + issue: &Issue{ + Description: `Attached_Molecule: mol-jkl +ATTACHED_AT: 2025-12-21T14:00:00Z`, + }, + wantFields: &AttachmentFields{ + AttachedMolecule: "mol-jkl", + AttachedAt: "2025-12-21T14:00:00Z", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := ParseAttachmentFields(tt.issue) + + if tt.wantNil { + if fields != nil { + t.Errorf("ParseAttachmentFields() = %+v, want nil", fields) + } + return + } + + if fields == nil { + t.Fatal("ParseAttachmentFields() = nil, want non-nil") + } + + if fields.AttachedMolecule != tt.wantFields.AttachedMolecule { + t.Errorf("AttachedMolecule = %q, want %q", fields.AttachedMolecule, tt.wantFields.AttachedMolecule) + } + if fields.AttachedAt != tt.wantFields.AttachedAt { + t.Errorf("AttachedAt = %q, want %q", fields.AttachedAt, tt.wantFields.AttachedAt) + } + }) + } +} + +// TestFormatAttachmentFields tests formatting attachment fields to string. +func TestFormatAttachmentFields(t *testing.T) { + tests := []struct { + name string + fields *AttachmentFields + want string + }{ + { + name: "nil fields", + fields: nil, + want: "", + }, + { + name: "empty fields", + fields: &AttachmentFields{}, + want: "", + }, + { + name: "both fields", + fields: &AttachmentFields{ + AttachedMolecule: "mol-xyz", + AttachedAt: "2025-12-21T15:30:00Z", + }, + want: `attached_molecule: mol-xyz +attached_at: 2025-12-21T15:30:00Z`, + }, + { + name: "only molecule", + fields: &AttachmentFields{ + AttachedMolecule: "mol-abc", + }, + want: "attached_molecule: mol-abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatAttachmentFields(tt.fields) + if got != tt.want { + t.Errorf("FormatAttachmentFields() =\n%q\nwant\n%q", got, tt.want) + } + }) + } +} + +// TestSetAttachmentFields tests updating issue descriptions with attachment fields. +func TestSetAttachmentFields(t *testing.T) { + tests := []struct { + name string + issue *Issue + fields *AttachmentFields + want string + }{ + { + name: "nil issue", + issue: nil, + fields: &AttachmentFields{ + AttachedMolecule: "mol-xyz", + AttachedAt: "2025-12-21T15:30:00Z", + }, + want: `attached_molecule: mol-xyz +attached_at: 2025-12-21T15:30:00Z`, + }, + { + name: "empty description", + issue: &Issue{Description: ""}, + fields: &AttachmentFields{ + AttachedMolecule: "mol-abc", + AttachedAt: "2025-12-21T10:00:00Z", + }, + want: `attached_molecule: mol-abc +attached_at: 2025-12-21T10:00:00Z`, + }, + { + name: "preserve prose content", + issue: &Issue{Description: "This is a handoff bead description.\n\nKeep working on the task."}, + fields: &AttachmentFields{ + AttachedMolecule: "mol-def", + }, + want: `attached_molecule: mol-def + +This is a handoff bead description. + +Keep working on the task.`, + }, + { + name: "replace existing fields", + issue: &Issue{ + Description: `attached_molecule: mol-old +attached_at: 2025-12-20T10:00:00Z + +Some existing prose content.`, + }, + fields: &AttachmentFields{ + AttachedMolecule: "mol-new", + AttachedAt: "2025-12-21T15:30:00Z", + }, + want: `attached_molecule: mol-new +attached_at: 2025-12-21T15:30:00Z + +Some existing prose content.`, + }, + { + name: "nil fields clears attachment", + issue: &Issue{Description: "attached_molecule: mol-old\nattached_at: 2025-12-20T10:00:00Z\n\nKeep this text."}, + fields: nil, + want: "Keep this text.", + }, + { + name: "empty fields clears attachment", + issue: &Issue{Description: "attached_molecule: mol-old\n\nKeep this text."}, + fields: &AttachmentFields{}, + want: "Keep this text.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SetAttachmentFields(tt.issue, tt.fields) + if got != tt.want { + t.Errorf("SetAttachmentFields() =\n%q\nwant\n%q", got, tt.want) + } + }) + } +} + +// TestAttachmentFieldsRoundTrip tests that parse/format round-trips correctly. +func TestAttachmentFieldsRoundTrip(t *testing.T) { + original := &AttachmentFields{ + AttachedMolecule: "mol-roundtrip", + AttachedAt: "2025-12-21T15:30:00Z", + } + + // Format to string + formatted := FormatAttachmentFields(original) + + // Parse back + issue := &Issue{Description: formatted} + parsed := ParseAttachmentFields(issue) + + if parsed == nil { + t.Fatal("round-trip parse returned nil") + } + + if *parsed != *original { + t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original) + } +} diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index e684dfa0..bb8a3260 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -131,6 +131,44 @@ Example: RunE: runMoleculeProgress, } +var moleculeAttachCmd = &cobra.Command{ + Use: "attach ", + Short: "Attach a molecule to a pinned bead", + Long: `Attach a molecule to a pinned/handoff bead. + +This records which molecule an agent is currently working on. The attachment +is stored in the pinned bead's description and visible via 'bd show'. + +Example: + gt molecule attach gt-abc mol-xyz`, + Args: cobra.ExactArgs(2), + RunE: runMoleculeAttach, +} + +var moleculeDetachCmd = &cobra.Command{ + Use: "detach ", + Short: "Detach molecule from a pinned bead", + Long: `Remove molecule attachment from a pinned/handoff bead. + +This clears the attached_molecule and attached_at fields from the bead. + +Example: + gt molecule detach gt-abc`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeDetach, +} + +var moleculeAttachmentCmd = &cobra.Command{ + Use: "attachment ", + Short: "Show attachment status of a pinned bead", + Long: `Show which molecule is attached to a pinned bead. + +Example: + gt molecule attachment gt-abc`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeAttachment, +} + func init() { // List flags moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") @@ -154,6 +192,9 @@ func init() { // Progress flags moleculeProgressCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + // Attachment flags + moleculeAttachmentCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + // Add subcommands moleculeCmd.AddCommand(moleculeListCmd) moleculeCmd.AddCommand(moleculeShowCmd) @@ -162,6 +203,9 @@ func init() { moleculeCmd.AddCommand(moleculeInstancesCmd) moleculeCmd.AddCommand(moleculeExportCmd) moleculeCmd.AddCommand(moleculeProgressCmd) + moleculeCmd.AddCommand(moleculeAttachCmd) + moleculeCmd.AddCommand(moleculeDetachCmd) + moleculeCmd.AddCommand(moleculeAttachmentCmd) rootCmd.AddCommand(moleculeCmd) } @@ -818,3 +862,120 @@ func extractMoleculeID(description string) string { } return "" } + +func runMoleculeAttach(cmd *cobra.Command, args []string) error { + pinnedBeadID := args[0] + moleculeID := args[1] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Attach the molecule + issue, err := b.AttachMolecule(pinnedBeadID, moleculeID) + if err != nil { + return fmt.Errorf("attaching molecule: %w", err) + } + + attachment := beads.ParseAttachmentFields(issue) + fmt.Printf("%s Attached %s to %s\n", style.Bold.Render("✓"), moleculeID, pinnedBeadID) + if attachment != nil && attachment.AttachedAt != "" { + fmt.Printf(" attached_at: %s\n", attachment.AttachedAt) + } + + return nil +} + +func runMoleculeDetach(cmd *cobra.Command, args []string) error { + pinnedBeadID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Check current attachment first + attachment, err := b.GetAttachment(pinnedBeadID) + if err != nil { + return fmt.Errorf("checking attachment: %w", err) + } + + if attachment == nil { + fmt.Printf("%s No molecule attached to %s\n", style.Dim.Render("ℹ"), pinnedBeadID) + return nil + } + + previousMolecule := attachment.AttachedMolecule + + // Detach the molecule + _, err = b.DetachMolecule(pinnedBeadID) + if err != nil { + return fmt.Errorf("detaching molecule: %w", err) + } + + fmt.Printf("%s Detached %s from %s\n", style.Bold.Render("✓"), previousMolecule, pinnedBeadID) + + return nil +} + +func runMoleculeAttachment(cmd *cobra.Command, args []string) error { + pinnedBeadID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Get the issue + issue, err := b.Show(pinnedBeadID) + if err != nil { + return fmt.Errorf("getting issue: %w", err) + } + + attachment := beads.ParseAttachmentFields(issue) + + if moleculeJSON { + type attachmentOutput struct { + IssueID string `json:"issue_id"` + IssueTitle string `json:"issue_title"` + Status string `json:"status"` + AttachedMolecule string `json:"attached_molecule,omitempty"` + AttachedAt string `json:"attached_at,omitempty"` + } + out := attachmentOutput{ + IssueID: issue.ID, + IssueTitle: issue.Title, + Status: issue.Status, + } + if attachment != nil { + out.AttachedMolecule = attachment.AttachedMolecule + out.AttachedAt = attachment.AttachedAt + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + // Human-readable output + fmt.Printf("\n%s: %s\n", style.Bold.Render(issue.ID), issue.Title) + fmt.Printf("Status: %s\n", issue.Status) + + if attachment == nil || attachment.AttachedMolecule == "" { + fmt.Printf("\n%s\n", style.Dim.Render("No molecule attached")) + } else { + fmt.Printf("\n%s\n", style.Bold.Render("Attached Molecule:")) + fmt.Printf(" ID: %s\n", attachment.AttachedMolecule) + if attachment.AttachedAt != "" { + fmt.Printf(" Attached at: %s\n", attachment.AttachedAt) + } + } + + return nil +}