From 4f99617b49dd1d0352e45f8289d88a31e781eb64 Mon Sep 17 00:00:00 2001 From: gastown/polecats/furiosa Date: Tue, 30 Dec 2025 22:38:04 -0800 Subject: [PATCH] Add agent bead lifecycle to gt dog add/remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When adding a dog, creates an agent bead with role_type:dog label. When removing a dog, deletes the corresponding agent bead. This enables @dogs group resolution in the mail router by allowing queries like `bd list --type=agent --label=role_type:dog`. Changes: - Add DogBeadID(), DogRoleBeadID() helper functions - Add CreateDogAgentBead() for creating dog agent beads with labels - Add FindDogAgentBead() and DeleteDogAgentBead() for cleanup - Add Labels field to Issue struct for label parsing - Update ParseAgentBeadID() to handle dog bead IDs (gt-dog-) - Update IsAgentSessionBead() to include "dog" as valid role (gt-qha0g) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads.go | 111 +++++++++++++++++++++++++++++++++++++++- internal/cmd/dog.go | 31 +++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 65911db6..2c22a2ea 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -100,6 +100,7 @@ type Issue struct { DependsOn []string `json:"depends_on,omitempty"` Blocks []string `json:"blocks,omitempty"` BlockedBy []string `json:"blocked_by,omitempty"` + Labels []string `json:"labels,omitempty"` // Agent bead slots (type=agent only) HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook @@ -996,6 +997,101 @@ func DeaconBeadID() string { return "gt-deacon" } +// DogBeadID returns a Dog agent bead ID. +// Dogs are town-level agents, so they follow the pattern: gt-dog- +func DogBeadID(name string) string { + return "gt-dog-" + name +} + +// DogRoleBeadID returns the Dog role bead ID. +func DogRoleBeadID() string { + return RoleBeadID("dog") +} + +// CreateDogAgentBead creates an agent bead for a dog. +// Dogs use a different schema than other agents - they use labels for metadata. +// Returns the created issue or an error. +func (b *Beads) CreateDogAgentBead(name, location string) (*Issue, error) { + title := fmt.Sprintf("Dog: %s", name) + labels := []string{ + "role_type:dog", + "rig:town", + "location:" + location, + } + + args := []string{ + "create", "--json", + "--type=agent", + "--role-type=dog", + "--title=" + title, + "--labels=" + strings.Join(labels, ","), + } + + // Default actor from BD_ACTOR env var for provenance tracking + if actor := os.Getenv("BD_ACTOR"); actor != "" { + args = append(args, "--actor="+actor) + } + + 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 +} + +// FindDogAgentBead finds the agent bead for a dog by name. +// Searches for agent beads with role_type:dog and matching title. +// Returns nil if not found. +func (b *Beads) FindDogAgentBead(name string) (*Issue, error) { + // List all agent beads and filter by role_type:dog label + issues, err := b.List(ListOptions{ + Type: "agent", + Status: "all", + Priority: -1, // No priority filter + }) + if err != nil { + return nil, fmt.Errorf("listing agents: %w", err) + } + + expectedTitle := fmt.Sprintf("Dog: %s", name) + for _, issue := range issues { + // Check title match and role_type:dog label + if issue.Title == expectedTitle { + for _, label := range issue.Labels { + if label == "role_type:dog" { + return issue, nil + } + } + } + } + + return nil, nil +} + +// DeleteDogAgentBead finds and deletes the agent bead for a dog. +// Returns nil if the bead doesn't exist (idempotent). +func (b *Beads) DeleteDogAgentBead(name string) error { + issue, err := b.FindDogAgentBead(name) + if err != nil { + return fmt.Errorf("finding dog bead: %w", err) + } + if issue == nil { + return nil // Already doesn't exist - idempotent + } + + err = b.DeleteAgentBead(issue.ID) + if err != nil { + return fmt.Errorf("deleting bead %s: %w", issue.ID, err) + } + return nil +} + // WitnessBeadIDWithPrefix returns the Witness agent bead ID for a rig using the specified prefix. func WitnessBeadIDWithPrefix(prefix, rig string) string { return AgentBeadIDWithPrefix(prefix, rig, "witness", "") @@ -1057,14 +1153,25 @@ func ParseAgentBeadID(id string) (rig, role, name string, ok bool) { // Town-level: gt-mayor, bd-deacon return "", parts[0], "", true case 2: - // Rig-level singleton: gt-gastown-witness, bd-beads-witness + // Could be rig-level singleton (gt-gastown-witness) or + // town-level named (gt-dog-alpha for dogs) + if parts[0] == "dog" { + // Dogs are town-level named agents: gt-dog- + return "", "dog", parts[1], true + } + // Rig-level singleton: gt-gastown-witness return parts[0], parts[1], "", true case 3: // Rig-level named: gt-gastown-crew-max, bd-beads-polecat-pearl return parts[0], parts[1], parts[2], true default: // Handle names with hyphens: gt-gastown-polecat-my-agent-name + // or gt-dog-my-agent-name if len(parts) >= 3 { + if parts[0] == "dog" { + // Dog with hyphenated name: gt-dog-my-dog-name + return "", "dog", strings.Join(parts[1:], "-"), true + } return parts[0], parts[1], strings.Join(parts[2:], "-"), true } return "", "", "", false @@ -1082,7 +1189,7 @@ func IsAgentSessionBead(beadID string) bool { } // Known agent roles switch role { - case "mayor", "deacon", "witness", "refinery", "crew", "polecat": + case "mayor", "deacon", "witness", "refinery", "crew", "polecat", "dog": return true default: return false diff --git a/internal/cmd/dog.go b/internal/cmd/dog.go index fda3815e..8a075fdf 100644 --- a/internal/cmd/dog.go +++ b/internal/cmd/dog.go @@ -9,6 +9,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/dog" "github.com/steveyegge/gastown/internal/style" @@ -201,6 +202,21 @@ func runDogAdd(cmd *cobra.Command, args []string) error { fmt.Printf(" %s: %s\n", rigName, path) } + // Create agent bead for the dog + townRoot, _ := workspace.FindFromCwd() + if townRoot != "" { + b := beads.New(townRoot) + location := filepath.Join("deacon", "dogs", name) + + issue, err := b.CreateDogAgentBead(name, location) + if err != nil { + // Non-fatal: warn but don't fail dog creation + fmt.Printf(" Warning: could not create agent bead: %v\n", err) + } else { + fmt.Printf(" Agent bead: %s\n", issue.ID) + } + } + return nil } @@ -227,6 +243,13 @@ func runDogRemove(cmd *cobra.Command, args []string) error { names = args } + // Get beads client for cleanup + townRoot, _ := workspace.FindFromCwd() + var b *beads.Beads + if townRoot != "" { + b = beads.New(townRoot) + } + for _, name := range names { d, err := mgr.Get(name) if err != nil { @@ -244,6 +267,14 @@ func runDogRemove(cmd *cobra.Command, args []string) error { } fmt.Printf("✓ Removed dog %s\n", name) + + // Delete agent bead for the dog + if b != nil { + if err := b.DeleteDogAgentBead(name); err != nil { + // Non-fatal: warn but don't fail dog removal + fmt.Printf(" Warning: could not delete agent bead: %v\n", err) + } + } } return nil