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