// Package beads provides a wrapper for the bd (beads) CLI. package beads import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // 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") ) // 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"` 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"` // 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 } // 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 (gt-ktf3). 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) } 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 } // Pin pins an issue to an agent's hook. // This sets the pinned boolean field and optionally the assignee. // If agent is empty, only sets the pinned field. // NOTE: There's a known issue (gt-o3is) where the pinned field gets overwritten // by subsequent bd commands due to a bug in beads auto-import logic. // The handoff bead attachment mechanism works correctly and is the primary // work assignment mechanism. The pinned field is cosmetic for bd hook visibility. func (b *Beads) Pin(id string, agent string) error { args := []string{"--no-daemon", "pin", id} if agent != "" { args = append(args, "--for="+agent) } _, err := b.run(args...) return err } // Unpin removes the pinned flag from an issue. func (b *Beads) Unpin(id string) error { args := []string{"unpin", id, "--json"} _, 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) } // 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 }) 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 } // 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 { 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 }