diff --git a/cmd/bd/agent.go b/cmd/bd/agent.go index 15e85ab9..738fcfa8 100644 --- a/cmd/bd/agent.go +++ b/cmd/bd/agent.go @@ -96,10 +96,36 @@ Examples: RunE: runAgentShow, } +var agentBackfillLabelsCmd = &cobra.Command{ + Use: "backfill-labels", + Short: "Backfill role_type/rig labels on existing agent beads", + Long: `Backfill role_type and rig labels on existing agent beads. + +This command scans all agent beads and: +1. Extracts role_type and rig from description text if fields are empty +2. Sets the role_type and rig fields on the agent bead +3. Adds role_type: and rig: labels for filtering + +This enables queries like: + bd list --type=agent --label=role_type:witness + bd list --type=agent --label=rig:gastown + +Use --dry-run to see what would be changed without making changes. + +Examples: + bd agent backfill-labels # Backfill all agent beads + bd agent backfill-labels --dry-run # Preview changes without applying`, + RunE: runAgentBackfillLabels, +} + +var backfillDryRun bool + func init() { + agentBackfillLabelsCmd.Flags().BoolVar(&backfillDryRun, "dry-run", false, "Preview changes without applying them") agentCmd.AddCommand(agentStateCmd) agentCmd.AddCommand(agentHeartbeatCmd) agentCmd.AddCommand(agentShowCmd) + agentCmd.AddCommand(agentBackfillLabelsCmd) rootCmd.AddCommand(agentCmd) } @@ -402,3 +428,202 @@ func formatTimeOrNil(t *time.Time) interface{} { } return t.Format(time.RFC3339) } + +// runAgentBackfillLabels scans all agent beads and adds role_type/rig labels +func runAgentBackfillLabels(cmd *cobra.Command, args []string) error { + if !backfillDryRun { + CheckReadonly("agent backfill-labels") + } + + ctx := rootCtx + + // List all agent beads + var agents []*types.Issue + if daemonClient != nil { + resp, err := daemonClient.List(&rpc.ListArgs{ + IssueType: "agent", + }) + if err != nil { + return fmt.Errorf("failed to list agents: %w", err) + } + if err := json.Unmarshal(resp.Data, &agents); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + agentType := types.TypeAgent + filter := types.IssueFilter{ + IssueType: &agentType, + } + var err error + agents, err = store.SearchIssues(ctx, "", filter) + if err != nil { + return fmt.Errorf("failed to list agents: %w", err) + } + } + + if len(agents) == 0 { + fmt.Println("No agent beads found") + return nil + } + + updated := 0 + skipped := 0 + + for _, agent := range agents { + // Skip tombstoned agents + if agent.Status == types.StatusTombstone { + continue + } + + // Extract role_type and rig from description if not set in fields + roleType := agent.RoleType + rig := agent.Rig + + if roleType == "" || rig == "" { + // Parse from description + lines := strings.Split(agent.Description, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "role_type:") && roleType == "" { + roleType = strings.TrimSpace(strings.TrimPrefix(line, "role_type:")) + } + if strings.HasPrefix(line, "rig:") && rig == "" { + rig = strings.TrimSpace(strings.TrimPrefix(line, "rig:")) + } + } + } + + // Skip if no role_type or rig found + if roleType == "" && rig == "" { + skipped++ + continue + } + + // Check if labels already exist + var existingLabels []string + if daemonClient != nil { + // Use show to get full issue with labels + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agent.ID}) + if err == nil { + var fullAgent types.Issue + if err := json.Unmarshal(resp.Data, &fullAgent); err == nil { + existingLabels = fullAgent.Labels + } + } + } else { + existingLabels, _ = store.GetLabels(ctx, agent.ID) + } + + // Determine which labels need to be added + needsRoleTypeLabel := roleType != "" && !containsLabel(existingLabels, "role_type:"+roleType) + needsRigLabel := rig != "" && !containsLabel(existingLabels, "rig:"+rig) + needsFieldUpdate := (roleType != "" && agent.RoleType == "") || (rig != "" && agent.Rig == "") + + if !needsRoleTypeLabel && !needsRigLabel && !needsFieldUpdate { + skipped++ + continue + } + + if backfillDryRun { + fmt.Printf("Would update %s:\n", agent.ID) + if needsFieldUpdate { + if roleType != "" && agent.RoleType == "" { + fmt.Printf(" Set role_type: %s\n", roleType) + } + if rig != "" && agent.Rig == "" { + fmt.Printf(" Set rig: %s\n", rig) + } + } + if needsRoleTypeLabel { + fmt.Printf(" Add label: role_type:%s\n", roleType) + } + if needsRigLabel { + fmt.Printf(" Add label: rig:%s\n", rig) + } + updated++ + continue + } + + // Update fields if needed + if needsFieldUpdate { + updates := map[string]interface{}{} + if roleType != "" && agent.RoleType == "" { + updates["role_type"] = roleType + } + if rig != "" && agent.Rig == "" { + updates["rig"] = rig + } + + if daemonClient != nil { + updateArgs := &rpc.UpdateArgs{ID: agent.ID} + if _, ok := updates["role_type"]; ok { + rt := roleType + updateArgs.RoleType = &rt + } + if _, ok := updates["rig"]; ok { + r := rig + updateArgs.Rig = &r + } + if _, err := daemonClient.Update(updateArgs); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update fields for %s: %v\n", agent.ID, err) + } + } else { + if err := store.UpdateIssue(ctx, agent.ID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update fields for %s: %v\n", agent.ID, err) + } + } + } + + // Add labels + if needsRoleTypeLabel { + label := "role_type:" + roleType + if daemonClient != nil { + if _, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: agent.ID, Label: label}); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to add label %s to %s: %v\n", label, agent.ID, err) + } + } else { + if err := store.AddLabel(ctx, agent.ID, label, actor); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to add label %s to %s: %v\n", label, agent.ID, err) + } + } + } + if needsRigLabel { + label := "rig:" + rig + if daemonClient != nil { + if _, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: agent.ID, Label: label}); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to add label %s to %s: %v\n", label, agent.ID, err) + } + } else { + if err := store.AddLabel(ctx, agent.ID, label, actor); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to add label %s to %s: %v\n", label, agent.ID, err) + } + } + } + + fmt.Printf("%s Updated %s (role_type:%s, rig:%s)\n", ui.RenderPass("✓"), agent.ID, roleType, rig) + updated++ + } + + // Trigger auto-flush + if flushManager != nil && !backfillDryRun { + flushManager.MarkDirty(false) + } + + if backfillDryRun { + fmt.Printf("\nDry run complete: %d would be updated, %d skipped\n", updated, skipped) + } else { + fmt.Printf("\nBackfill complete: %d updated, %d skipped\n", updated, skipped) + } + + return nil +} + +// containsLabel checks if a label exists in the list +func containsLabel(labels []string, label string) bool { + for _, l := range labels { + if l == label { + return true + } + } + return false +} diff --git a/cmd/bd/create.go b/cmd/bd/create.go index e66ac680..2b7266c2 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -121,6 +121,15 @@ var createCmd = &cobra.Command{ } } + // Agent-specific flags + roleType, _ := cmd.Flags().GetString("role-type") + agentRig, _ := cmd.Flags().GetString("agent-rig") + + // Validate agent-specific flags require --type=agent + if (roleType != "" || agentRig != "") && issueType != "agent" { + FatalError("--role-type and --agent-rig flags require --type=agent") + } + // Handle --rig or --prefix flag: create issue in a different rig // Both flags use the same forgiving lookup (accepts rig names or prefixes) targetRig := rigOverride @@ -258,6 +267,8 @@ var createCmd = &cobra.Command{ Ephemeral: wisp, CreatedBy: getActorWithGit(), MolType: string(molType), + RoleType: roleType, + Rig: agentRig, } resp, err := daemonClient.Create(createArgs) @@ -305,6 +316,8 @@ var createCmd = &cobra.Command{ Ephemeral: wisp, CreatedBy: getActorWithGit(), MolType: molType, + RoleType: roleType, + Rig: agentRig, } ctx := rootCtx @@ -367,6 +380,22 @@ var createCmd = &cobra.Command{ } } + // Auto-add role_type/rig labels for agent beads (enables filtering queries) + if issue.IssueType == types.TypeAgent { + if issue.RoleType != "" { + agentLabel := "role_type:" + issue.RoleType + if err := store.AddLabel(ctx, issue.ID, agentLabel, actor); err != nil { + WarnError("failed to add role_type label: %v", err) + } + } + if issue.Rig != "" { + rigLabel := "rig:" + issue.Rig + if err := store.AddLabel(ctx, issue.ID, rigLabel, actor); err != nil { + WarnError("failed to add rig label: %v", err) + } + } + } + // Add dependencies if specified (format: type:id or just id for default "blocks" type) for _, depSpec := range deps { // Skip empty specs (e.g., from trailing commas) @@ -487,6 +516,9 @@ func init() { createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)") createCmd.Flags().Bool("ephemeral", false, "Create as ephemeral (ephemeral, not exported to JSONL)") createCmd.Flags().String("mol-type", "", "Molecule type: swarm (multi-polecat), patrol (recurring ops), work (default)") + // Agent-specific flags (only valid when --type=agent) + createCmd.Flags().String("role-type", "", "Agent role type: polecat|crew|witness|refinery|mayor|deacon (requires --type=agent)") + createCmd.Flags().String("agent-rig", "", "Agent's rig name (requires --type=agent)") // Note: --json flag is defined as a persistent flag in main.go, not here rootCmd.AddCommand(createCmd) } diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 78897736..490106db 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -97,6 +97,9 @@ type CreateArgs struct { CreatedBy string `json:"created_by,omitempty"` // Who created the issue // Molecule type (for swarm coordination) MolType string `json:"mol_type,omitempty"` // swarm, patrol, or work (default) + // Agent identity fields (only valid when IssueType == "agent") + RoleType string `json:"role_type,omitempty"` // polecat|crew|witness|refinery|mayor|deacon + Rig string `json:"rig,omitempty"` // Rig name (empty for town-level agents) } // UpdateArgs represents arguments for the update operation @@ -134,6 +137,9 @@ type UpdateArgs struct { // Agent state fields AgentState *string `json:"agent_state,omitempty"` // Agent state (idle|running|stuck|stopped|dead) LastActivity *bool `json:"last_activity,omitempty"` // If true, update last_activity to now + // Agent identity fields + RoleType *string `json:"role_type,omitempty"` // polecat|crew|witness|refinery|mayor|deacon + Rig *string `json:"rig,omitempty"` // Rig name (empty for town-level agents) } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index a885c00d..2b3359f5 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -115,6 +115,13 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.LastActivity != nil && *a.LastActivity { u["last_activity"] = time.Now() } + // Agent identity fields + if a.RoleType != nil { + u["role_type"] = *a.RoleType + } + if a.Rig != nil { + u["rig"] = *a.Rig + } return u } @@ -198,6 +205,9 @@ func (s *Server) handleCreate(req *Request) Response { CreatedBy: createArgs.CreatedBy, // Molecule type MolType: types.MolType(createArgs.MolType), + // Agent identity fields + RoleType: createArgs.RoleType, + Rig: createArgs.Rig, } // Check if any dependencies are discovered-from type @@ -283,6 +293,28 @@ func (s *Server) handleCreate(req *Request) Response { } } + // Auto-add role_type/rig labels for agent beads (enables filtering queries) + if issue.IssueType == types.TypeAgent { + if issue.RoleType != "" { + label := "role_type:" + issue.RoleType + if err := store.AddLabel(ctx, issue.ID, label, s.reqActor(req)); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to add role_type label: %v", err), + } + } + } + if issue.Rig != "" { + label := "rig:" + issue.Rig + if err := store.AddLabel(ctx, issue.ID, label, s.reqActor(req)); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to add rig label: %v", err), + } + } + } + } + // Add dependencies if specified for _, depSpec := range createArgs.Dependencies { depSpec = strings.TrimSpace(depSpec) @@ -482,6 +514,29 @@ func (s *Server) handleUpdate(req *Request) Response { } } + // Auto-add role_type/rig labels for agent beads when these fields are set + // This enables filtering queries like: bd list --type=agent --label=role_type:witness + if issue.IssueType == types.TypeAgent { + if updateArgs.RoleType != nil && *updateArgs.RoleType != "" { + label := "role_type:" + *updateArgs.RoleType + if err := store.AddLabel(ctx, updateArgs.ID, label, actor); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to add role_type label: %v", err), + } + } + } + if updateArgs.Rig != nil && *updateArgs.Rig != "" { + label := "rig:" + *updateArgs.Rig + if err := store.AddLabel(ctx, updateArgs.ID, label, actor); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to add rig label: %v", err), + } + } + } + } + // Handle reparenting if updateArgs.Parent != nil { newParentID := *updateArgs.Parent