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:
Steve Yegge
2025-12-29 21:15:46 -08:00
parent 22fb3ff56b
commit a8748936e4
4 changed files with 318 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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)
}