diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 47c07301..65911db6 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -706,6 +706,7 @@ type AgentFields struct { HookBead string // Currently pinned work bead ID RoleBead string // Role definition bead ID (canonical location; may not exist yet) CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed) + ActiveMR string // Currently active merge request bead ID (for traceability) } // FormatAgentDescription creates a description string from agent fields. @@ -745,6 +746,12 @@ func FormatAgentDescription(title string, fields *AgentFields) string { lines = append(lines, "cleanup_status: null") } + if fields.ActiveMR != "" { + lines = append(lines, fmt.Sprintf("active_mr: %s", fields.ActiveMR)) + } else { + lines = append(lines, "active_mr: null") + } + return strings.Join(lines, "\n") } @@ -782,6 +789,8 @@ func ParseAgentFields(description string) *AgentFields { fields.RoleBead = value case "cleanup_status": fields.CleanupStatus = value + case "active_mr": + fields.ActiveMR = value } } @@ -896,6 +905,26 @@ func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus string) error return b.Update(id, UpdateOptions{Description: &description}) } +// UpdateAgentActiveMR updates the active_mr field in an agent bead. +// This links the agent to their current merge request for traceability. +// Pass empty string to clear the field (e.g., after merge completes). +func (b *Beads) UpdateAgentActiveMR(id string, activeMR string) error { + // First get current issue to preserve other fields + issue, err := b.Show(id) + if err != nil { + return err + } + + // Parse existing fields + fields := ParseAgentFields(issue.Description) + fields.ActiveMR = activeMR + + // Format new description + description := FormatAgentDescription(issue.Title, fields) + + return b.Update(id, UpdateOptions{Description: &description}) +} + // DeleteAgentBead permanently deletes an agent bead. // Uses --hard --force for immediate permanent deletion (no tombstone). func (b *Beads) DeleteAgentBead(id string) error { diff --git a/internal/beads/fields.go b/internal/beads/fields.go index cbe3517f..12009280 100644 --- a/internal/beads/fields.go +++ b/internal/beads/fields.go @@ -164,6 +164,7 @@ type MRFields struct { Rig string // Which rig MergeCommit string // SHA of merge commit (set on close) CloseReason string // Reason for closing: merged, rejected, conflict, superseded + AgentBead string // Agent bead ID that created this MR (for traceability) } // ParseMRFields extracts structured merge-request fields from an issue's description. @@ -218,6 +219,9 @@ func ParseMRFields(issue *Issue) *MRFields { case "close_reason", "close-reason", "closereason": fields.CloseReason = value hasFields = true + case "agent_bead", "agent-bead", "agentbead": + fields.AgentBead = value + hasFields = true } } @@ -257,6 +261,9 @@ func FormatMRFields(fields *MRFields) string { if fields.CloseReason != "" { lines = append(lines, "close_reason: "+fields.CloseReason) } + if fields.AgentBead != "" { + lines = append(lines, "agent_bead: "+fields.AgentBead) + } return strings.Join(lines, "\n") } @@ -284,6 +291,9 @@ func SetMRFields(issue *Issue, fields *MRFields) string { "close_reason": true, "close-reason": true, "closereason": true, + "agent_bead": true, + "agent-bead": true, + "agentbead": true, } // Collect non-MR lines from existing description diff --git a/internal/cmd/done.go b/internal/cmd/done.go index 2767f857..a8c7c9f5 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -108,6 +108,19 @@ func runDone(cmd *cobra.Command, args []string) error { polecatName = parts[len(parts)-1] } + // Get agent bead ID for cross-referencing + var agentBeadID string + if roleInfo, err := GetRoleWithContext(cwd, townRoot); err == nil { + ctx := RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } + agentBeadID = getAgentBeadID(ctx) + } + // For COMPLETED, we need an issue ID and branch must not be main var mrID string if exitType == ExitCompleted { @@ -173,6 +186,9 @@ func runDone(cmd *cobra.Command, args []string) error { if worker != "" { description += fmt.Sprintf("\nworker: %s", worker) } + if agentBeadID != "" { + description += fmt.Sprintf("\nagent_bead: %s", agentBeadID) + } // Create MR bead (ephemeral wisp - will be cleaned up after merge) mrIssue, err := bd.Create(beads.CreateOptions{ @@ -186,6 +202,13 @@ func runDone(cmd *cobra.Command, args []string) error { } mrID = mrIssue.ID + // Update agent bead with active_mr reference (for traceability) + if agentBeadID != "" { + if err := bd.UpdateAgentActiveMR(agentBeadID, mrID); err != nil { + style.PrintWarning("could not update agent bead with active_mr: %v", err) + } + } + // Success output fmt.Printf("%s Work submitted to merge queue\n", style.Bold.Render("✓")) fmt.Printf(" MR ID: %s\n", style.Bold.Render(mrID)) diff --git a/internal/mrqueue/mrqueue.go b/internal/mrqueue/mrqueue.go index 5ae5cac8..0e9ee411 100644 --- a/internal/mrqueue/mrqueue.go +++ b/internal/mrqueue/mrqueue.go @@ -26,6 +26,7 @@ type MR struct { Title string `json:"title"` // MR title Priority int `json:"priority"` // Priority (lower = higher priority) CreatedAt time.Time `json:"created_at"` + AgentBead string `json:"agent_bead,omitempty"` // Agent bead ID that created this MR (for traceability) } // Queue manages the MR storage. diff --git a/internal/refinery/engineer.go b/internal/refinery/engineer.go index 4b74d973..4878ff0c 100644 --- a/internal/refinery/engineer.go +++ b/internal/refinery/engineer.go @@ -262,6 +262,13 @@ func (e *Engineer) handleSuccess(mr *beads.Issue, result ProcessResult) { } } + // 3.5. Clear agent bead's active_mr reference (traceability cleanup) + if mrFields.AgentBead != "" { + if err := e.beads.UpdateAgentActiveMR(mrFields.AgentBead, ""); err != nil { + fmt.Fprintf(e.output, "[Engineer] Warning: failed to clear agent bead %s active_mr: %v\n", mrFields.AgentBead, err) + } + } + // 4. Delete source branch if configured (local only - branches never go to origin) if e.config.DeleteMergedBranches && mrFields.Branch != "" { if err := e.git.DeleteBranch(mrFields.Branch, true); err != nil { @@ -328,6 +335,13 @@ func (e *Engineer) handleSuccessFromQueue(mr *mrqueue.MR, result ProcessResult) } } + // 1.5. Clear agent bead's active_mr reference (traceability cleanup) + if mr.AgentBead != "" { + if err := e.beads.UpdateAgentActiveMR(mr.AgentBead, ""); err != nil { + fmt.Fprintf(e.output, "[Engineer] Warning: failed to clear agent bead %s active_mr: %v\n", mr.AgentBead, err) + } + } + // 2. Delete source branch if configured (local only) if e.config.DeleteMergedBranches && mr.Branch != "" { if err := e.git.DeleteBranch(mr.Branch, true); err != nil {