diff --git a/internal/beads/beads.go b/internal/beads/beads.go index ecc2f082..989b67e4 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -512,6 +512,49 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) { return &issue, nil } +// CreateWithID creates an issue with a specific ID. +// This is useful for agent beads, role beads, and other beads that need +// deterministic IDs rather than auto-generated ones. +func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) { + args := []string{"create", "--json", "--id=" + id} + + if opts.Title != "" { + args = append(args, "--title="+opts.Title) + } + if opts.Type != "" { + args = append(args, "--type="+opts.Type) + } + if opts.Priority >= 0 { + args = append(args, fmt.Sprintf("--priority=%d", opts.Priority)) + } + if opts.Description != "" { + args = append(args, "--description="+opts.Description) + } + if opts.Parent != "" { + args = append(args, "--parent="+opts.Parent) + } + // Default Actor from BD_ACTOR env var if not specified + actor := opts.Actor + if actor == "" { + actor = os.Getenv("BD_ACTOR") + } + if 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 +} + // Update updates an existing issue. func (b *Beads) Update(id string, opts UpdateOptions) error { args := []string{"update", id} @@ -1146,6 +1189,36 @@ func DogRoleBeadID() string { return RoleBeadID("dog") } +// Town-level agent bead ID helpers (hq- prefix, stored in town beads). +// These are the canonical IDs for the two-level beads architecture: +// - Town-level agents (Mayor, Deacon, Dogs) → ~/gt/.beads/ with hq- prefix +// - Rig-level agents (Witness, Refinery, Polecats) → /.beads/ with rig prefix + +// MayorBeadIDTown returns the town-level Mayor agent bead ID. +// Deprecated: Use MayorBeadID() which still returns gt-mayor for compatibility. +// After migration to two-level architecture, this will become the canonical ID. +func MayorBeadIDTown() string { + return "hq-mayor" +} + +// DeaconBeadIDTown returns the town-level Deacon agent bead ID. +// Deprecated: Use DeaconBeadID() which still returns gt-deacon for compatibility. +// After migration to two-level architecture, this will become the canonical ID. +func DeaconBeadIDTown() string { + return "hq-deacon" +} + +// DogBeadIDTown returns a town-level Dog agent bead ID. +func DogBeadIDTown(name string) string { + return "hq-dog-" + name +} + +// RoleBeadIDTown returns a town-level role definition bead ID. +// Role beads are global templates stored in town beads. +func RoleBeadIDTown(role string) string { + return "hq-" + role + "-role" +} + // 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. diff --git a/internal/cmd/migrate_agents.go b/internal/cmd/migrate_agents.go new file mode 100644 index 00000000..6d926086 --- /dev/null +++ b/internal/cmd/migrate_agents.go @@ -0,0 +1,321 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/workspace" +) + +var ( + migrateAgentsDryRun bool + migrateAgentsForce bool +) + +var migrateAgentsCmd = &cobra.Command{ + Use: "migrate-agents", + GroupID: GroupDiag, + Short: "Migrate agent beads to two-level architecture", + Long: `Migrate agent beads from the old single-tier to the two-level architecture. + +This command migrates town-level agent beads (Mayor, Deacon) from rig beads +with gt-* prefix to town beads with hq-* prefix: + + OLD (rig beads): gt-mayor, gt-deacon + NEW (town beads): hq-mayor, hq-deacon + +Rig-level agents (Witness, Refinery, Polecats) remain in rig beads unchanged. + +The migration: +1. Detects old gt-mayor/gt-deacon beads in rig beads +2. Creates new hq-mayor/hq-deacon beads in town beads +3. Copies agent state (hook_bead, agent_state, etc.) +4. Adds migration note to old beads (preserves them) + +Safety: +- Dry-run mode by default (use --execute to apply changes) +- Old beads are preserved with migration notes +- Validates new beads exist before marking migration complete +- Skips if new beads already exist (idempotent) + +Examples: + gt migrate-agents # Dry-run: show what would be migrated + gt migrate-agents --execute # Apply the migration + gt migrate-agents --force # Re-migrate even if new beads exist`, + RunE: runMigrateAgents, +} + +func init() { + migrateAgentsCmd.Flags().BoolVar(&migrateAgentsDryRun, "dry-run", true, "Show what would be migrated without making changes (default)") + migrateAgentsCmd.Flags().BoolVar(&migrateAgentsForce, "force", false, "Re-migrate even if new beads already exist") + // Add --execute as inverse of --dry-run for clarity + migrateAgentsCmd.Flags().BoolP("execute", "x", false, "Actually apply the migration (opposite of --dry-run)") + rootCmd.AddCommand(migrateAgentsCmd) +} + +// migrationResult holds the result of a single bead migration. +type migrationResult struct { + OldID string + NewID string + Status string // "migrated", "skipped", "error" + Message string + OldFields *beads.AgentFields + WasDryRun bool +} + +func runMigrateAgents(cmd *cobra.Command, args []string) error { + // Handle --execute flag + if execute, _ := cmd.Flags().GetBool("execute"); execute { + migrateAgentsDryRun = false + } + + // Find town root + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Get town beads path + townBeadsDir := filepath.Join(townRoot, ".beads") + + // Load routes to find rig beads + routes, err := beads.LoadRoutes(townBeadsDir) + if err != nil { + return fmt.Errorf("loading routes.jsonl: %w", err) + } + + // Find the first rig with gt- prefix (where global agents are currently stored) + var sourceRigPath string + for _, r := range routes { + if strings.TrimSuffix(r.Prefix, "-") == "gt" && r.Path != "." { + sourceRigPath = r.Path + break + } + } + + if sourceRigPath == "" { + fmt.Println("No rig with gt- prefix found. Nothing to migrate.") + return nil + } + + // Source beads (rig beads where old agent beads are) + sourceBeadsDir := filepath.Join(townRoot, sourceRigPath, ".beads") + sourceBd := beads.New(sourceBeadsDir) + + // Target beads (town beads where new agent beads should go) + targetBd := beads.NewWithBeadsDir(townRoot, townBeadsDir) + + // Agents to migrate: town-level agents only + agentsToMigrate := []struct { + oldID string + newID string + desc string + }{ + { + oldID: beads.MayorBeadID(), // gt-mayor + newID: beads.MayorBeadIDTown(), // hq-mayor + desc: "Mayor - global coordinator, handles cross-rig communication and escalations.", + }, + { + oldID: beads.DeaconBeadID(), // gt-deacon + newID: beads.DeaconBeadIDTown(), // hq-deacon + desc: "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring.", + }, + } + + // Also migrate role beads + rolesToMigrate := []string{"mayor", "deacon", "witness", "refinery", "polecat", "crew", "dog"} + + if migrateAgentsDryRun { + fmt.Println("🔍 DRY RUN: Showing what would be migrated") + fmt.Println(" Use --execute to apply changes") + fmt.Println() + } else { + fmt.Println("🚀 Migrating agent beads to two-level architecture") + fmt.Println() + } + + var results []migrationResult + + // Migrate agent beads + fmt.Println("Agent Beads:") + for _, agent := range agentsToMigrate { + result := migrateAgentBead(sourceBd, targetBd, agent.oldID, agent.newID, agent.desc, migrateAgentsDryRun, migrateAgentsForce) + results = append(results, result) + printMigrationResult(result) + } + + // Migrate role beads + fmt.Println("\nRole Beads:") + for _, role := range rolesToMigrate { + oldID := "gt-" + role + "-role" + newID := beads.RoleBeadIDTown(role) // hq--role + result := migrateRoleBead(sourceBd, targetBd, oldID, newID, role, migrateAgentsDryRun, migrateAgentsForce) + results = append(results, result) + printMigrationResult(result) + } + + // Summary + fmt.Println() + printMigrationSummary(results, migrateAgentsDryRun) + + return nil +} + +// migrateAgentBead migrates a single agent bead from source to target. +func migrateAgentBead(sourceBd, targetBd *beads.Beads, oldID, newID, desc string, dryRun, force bool) migrationResult { + result := migrationResult{ + OldID: oldID, + NewID: newID, + WasDryRun: dryRun, + } + + // Check if old bead exists + oldIssue, oldFields, err := sourceBd.GetAgentBead(oldID) + if err != nil { + result.Status = "skipped" + result.Message = "old bead not found" + return result + } + result.OldFields = oldFields + + // Check if new bead already exists + if _, err := targetBd.Show(newID); err == nil { + if !force { + result.Status = "skipped" + result.Message = "new bead already exists (use --force to re-migrate)" + return result + } + } + + if dryRun { + result.Status = "would migrate" + result.Message = fmt.Sprintf("would copy state from %s", oldIssue.ID) + return result + } + + // Create new bead in town beads + newFields := &beads.AgentFields{ + RoleType: oldFields.RoleType, + Rig: oldFields.Rig, + AgentState: oldFields.AgentState, + HookBead: oldFields.HookBead, + RoleBead: beads.RoleBeadIDTown(oldFields.RoleType), // Update to hq- role + CleanupStatus: oldFields.CleanupStatus, + ActiveMR: oldFields.ActiveMR, + NotificationLevel: oldFields.NotificationLevel, + } + + _, err = targetBd.CreateAgentBead(newID, desc, newFields) + if err != nil { + result.Status = "error" + result.Message = fmt.Sprintf("failed to create: %v", err) + return result + } + + // Add migration label to old bead + migrationLabel := fmt.Sprintf("migrated-to:%s", newID) + if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil { + // Non-fatal: just log it + result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err) + } + + result.Status = "migrated" + result.Message = "successfully migrated" + return result +} + +// migrateRoleBead migrates a role definition bead. +func migrateRoleBead(sourceBd, targetBd *beads.Beads, oldID, newID, role string, dryRun, force bool) migrationResult { + result := migrationResult{ + OldID: oldID, + NewID: newID, + WasDryRun: dryRun, + } + + // Check if old bead exists + oldIssue, err := sourceBd.Show(oldID) + if err != nil { + result.Status = "skipped" + result.Message = "old bead not found" + return result + } + + // Check if new bead already exists + if _, err := targetBd.Show(newID); err == nil { + if !force { + result.Status = "skipped" + result.Message = "new bead already exists (use --force to re-migrate)" + return result + } + } + + if dryRun { + result.Status = "would migrate" + result.Message = fmt.Sprintf("would copy from %s", oldIssue.ID) + return result + } + + // Create new role bead in town beads + // Role beads are simple - just copy the description + _, err = targetBd.CreateWithID(newID, beads.CreateOptions{ + Title: fmt.Sprintf("Role: %s", role), + Type: "role", + Description: oldIssue.Title, // Use old title as description + }) + if err != nil { + result.Status = "error" + result.Message = fmt.Sprintf("failed to create: %v", err) + return result + } + + // Add migration label to old bead + migrationLabel := fmt.Sprintf("migrated-to:%s", newID) + if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil { + // Non-fatal + result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err) + } + + result.Status = "migrated" + result.Message = "successfully migrated" + return result +} + +func printMigrationResult(r migrationResult) { + var icon string + switch r.Status { + case "migrated", "would migrate": + icon = " ✓" + case "skipped": + icon = " ⊘" + case "error": + icon = " ✗" + } + fmt.Printf("%s %s → %s: %s\n", icon, r.OldID, r.NewID, r.Message) +} + +func printMigrationSummary(results []migrationResult, dryRun bool) { + var migrated, skipped, errors int + for _, r := range results { + switch r.Status { + case "migrated", "would migrate": + migrated++ + case "skipped": + skipped++ + case "error": + errors++ + } + } + + if dryRun { + fmt.Printf("Summary (dry-run): %d would migrate, %d skipped, %d errors\n", migrated, skipped, errors) + if migrated > 0 { + fmt.Println("\nRun with --execute to apply these changes.") + } + } else { + fmt.Printf("Summary: %d migrated, %d skipped, %d errors\n", migrated, skipped, errors) + } +} diff --git a/internal/cmd/migrate_agents_test.go b/internal/cmd/migrate_agents_test.go new file mode 100644 index 00000000..33d28cc4 --- /dev/null +++ b/internal/cmd/migrate_agents_test.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "testing" + + "github.com/steveyegge/gastown/internal/beads" +) + +func TestMigrationResultStatus(t *testing.T) { + tests := []struct { + name string + result migrationResult + wantIcon string + }{ + { + name: "migrated shows checkmark", + result: migrationResult{ + OldID: "gt-mayor", + NewID: "hq-mayor", + Status: "migrated", + Message: "successfully migrated", + }, + wantIcon: "✓", + }, + { + name: "would migrate shows checkmark", + result: migrationResult{ + OldID: "gt-mayor", + NewID: "hq-mayor", + Status: "would migrate", + Message: "would copy state from gt-mayor", + }, + wantIcon: "✓", + }, + { + name: "skipped shows empty circle", + result: migrationResult{ + OldID: "gt-mayor", + NewID: "hq-mayor", + Status: "skipped", + Message: "already exists", + }, + wantIcon: "⊘", + }, + { + name: "error shows X", + result: migrationResult{ + OldID: "gt-mayor", + NewID: "hq-mayor", + Status: "error", + Message: "failed to create", + }, + wantIcon: "✗", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var icon string + switch tt.result.Status { + case "migrated", "would migrate": + icon = "✓" + case "skipped": + icon = "⊘" + case "error": + icon = "✗" + } + if icon != tt.wantIcon { + t.Errorf("icon for status %q = %q, want %q", tt.result.Status, icon, tt.wantIcon) + } + }) + } +} + +func TestTownBeadIDHelpers(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {"MayorBeadIDTown", beads.MayorBeadIDTown(), "hq-mayor"}, + {"DeaconBeadIDTown", beads.DeaconBeadIDTown(), "hq-deacon"}, + {"DogBeadIDTown", beads.DogBeadIDTown("fido"), "hq-dog-fido"}, + {"RoleBeadIDTown mayor", beads.RoleBeadIDTown("mayor"), "hq-mayor-role"}, + {"RoleBeadIDTown witness", beads.RoleBeadIDTown("witness"), "hq-witness-role"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want) + } + }) + } +}