feat: add structured labels for agent beads (bd-g7eq)
Add role_type and rig labels to agent beads for filtering queries. Changes: - Add RoleType/Rig to CreateArgs and UpdateArgs in RPC protocol - Auto-add role_type:<value> and rig:<value> labels when creating/updating agents - Add --role-type and --agent-rig flags to bd create (requires --type=agent) - Add bd agent backfill-labels command to update existing agent beads This enables queries like: bd list --type=agent --label=role_type:witness bd list --type=agent --label=rig:gastown 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
225
cmd/bd/agent.go
225
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:<value> and rig:<value> 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user