Add gt up --restore for crew and polecat restoration
Features:
- Add CrewConfig to RigSettings (settings/config.json)
- Add --restore flag to gt up
- Crew startup from natural language preferences (e.g., 'max', 'joe and max', 'all')
- Polecat restoration from hook files (work attached)
Example rig settings:
{"crew": {"startup": "max"}}
Usage:
gt up --restore
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+293
-1
@@ -1,10 +1,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/refinery"
|
"github.com/steveyegge/gastown/internal/refinery"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/wisp"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,17 +37,23 @@ infrastructure agents are running:
|
|||||||
Polecats are NOT started by this command - they are transient workers
|
Polecats are NOT started by this command - they are transient workers
|
||||||
spawned on demand by the Mayor or Witnesses.
|
spawned on demand by the Mayor or Witnesses.
|
||||||
|
|
||||||
|
Use --restore to also start:
|
||||||
|
• Crew - Per rig settings (settings/config.json crew.startup)
|
||||||
|
• Polecats - Those with hooks (work attached)
|
||||||
|
|
||||||
Running 'gt up' multiple times is safe - it only starts services that
|
Running 'gt up' multiple times is safe - it only starts services that
|
||||||
aren't already running.`,
|
aren't already running.`,
|
||||||
RunE: runUp,
|
RunE: runUp,
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
upQuiet bool
|
upQuiet bool
|
||||||
|
upRestore bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
upCmd.Flags().BoolVarP(&upQuiet, "quiet", "q", false, "Only show errors")
|
upCmd.Flags().BoolVarP(&upQuiet, "quiet", "q", false, "Only show errors")
|
||||||
|
upCmd.Flags().BoolVar(&upRestore, "restore", false, "Also restore crew (from settings) and polecats (from hooks)")
|
||||||
rootCmd.AddCommand(upCmd)
|
rootCmd.AddCommand(upCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +131,32 @@ func runUp(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Crew (if --restore)
|
||||||
|
if upRestore {
|
||||||
|
for _, rigName := range rigs {
|
||||||
|
crewStarted, crewErrors := startCrewFromSettings(t, townRoot, rigName)
|
||||||
|
for _, name := range crewStarted {
|
||||||
|
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-crew-%s", rigName, name))
|
||||||
|
}
|
||||||
|
for name, err := range crewErrors {
|
||||||
|
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), false, err.Error())
|
||||||
|
allOK = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Polecats with hooks (if --restore)
|
||||||
|
for _, rigName := range rigs {
|
||||||
|
polecatsStarted, polecatErrors := startPolecatsWithHooks(t, townRoot, rigName)
|
||||||
|
for _, name := range polecatsStarted {
|
||||||
|
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-polecat-%s", rigName, name))
|
||||||
|
}
|
||||||
|
for name, err := range polecatErrors {
|
||||||
|
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), false, err.Error())
|
||||||
|
allOK = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
if allOK {
|
if allOK {
|
||||||
fmt.Printf("%s All services running\n", style.Bold.Render("✓"))
|
fmt.Printf("%s All services running\n", style.Bold.Render("✓"))
|
||||||
@@ -315,3 +350,260 @@ func discoverRigs(townRoot string) []string {
|
|||||||
|
|
||||||
return rigs
|
return rigs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startCrewFromSettings starts crew members based on rig settings.
|
||||||
|
// Returns list of started crew names and map of errors.
|
||||||
|
func startCrewFromSettings(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) {
|
||||||
|
started := []string{}
|
||||||
|
errors := map[string]error{}
|
||||||
|
|
||||||
|
rigPath := filepath.Join(townRoot, rigName)
|
||||||
|
|
||||||
|
// Load rig settings
|
||||||
|
settingsPath := filepath.Join(rigPath, "settings", "config.json")
|
||||||
|
settings, err := config.LoadRigSettings(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
// No settings file or error - skip crew startup
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.Crew == nil || settings.Crew.Startup == "" {
|
||||||
|
// No crew startup preference
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available crew members using helper
|
||||||
|
crewMgr, _, err := getCrewManager(rigName)
|
||||||
|
if err != nil {
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
crewWorkers, err := crewMgr.List()
|
||||||
|
if err != nil {
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(crewWorkers) == 0 {
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract crew names
|
||||||
|
crewNames := make([]string, len(crewWorkers))
|
||||||
|
for i, w := range crewWorkers {
|
||||||
|
crewNames[i] = w.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse startup preference and determine which crew to start
|
||||||
|
toStart := parseCrewStartupPreference(settings.Crew.Startup, crewNames)
|
||||||
|
|
||||||
|
// Start each crew member
|
||||||
|
for _, crewName := range toStart {
|
||||||
|
sessionName := fmt.Sprintf("gt-%s-crew-%s", rigName, crewName)
|
||||||
|
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
|
if err != nil {
|
||||||
|
errors[crewName] = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
started = append(started, crewName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the crew member
|
||||||
|
crewPath := filepath.Join(rigPath, "crew", crewName)
|
||||||
|
if err := ensureCrewSession(t, sessionName, crewPath, rigName, crewName); err != nil {
|
||||||
|
errors[crewName] = err
|
||||||
|
} else {
|
||||||
|
started = append(started, crewName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCrewStartupPreference parses the natural language crew startup preference.
|
||||||
|
// Examples: "max", "joe and max", "all", "none", "pick one"
|
||||||
|
func parseCrewStartupPreference(pref string, available []string) []string {
|
||||||
|
pref = strings.ToLower(strings.TrimSpace(pref))
|
||||||
|
|
||||||
|
// Special keywords
|
||||||
|
switch pref {
|
||||||
|
case "none", "":
|
||||||
|
return []string{}
|
||||||
|
case "all":
|
||||||
|
return available
|
||||||
|
case "pick one", "any", "any one":
|
||||||
|
if len(available) > 0 {
|
||||||
|
return []string{available[0]}
|
||||||
|
}
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse comma/and-separated list
|
||||||
|
// "joe and max" -> ["joe", "max"]
|
||||||
|
// "joe, max" -> ["joe", "max"]
|
||||||
|
// "max" -> ["max"]
|
||||||
|
pref = strings.ReplaceAll(pref, " and ", ",")
|
||||||
|
pref = strings.ReplaceAll(pref, ", but not ", ",-")
|
||||||
|
pref = strings.ReplaceAll(pref, " but not ", ",-")
|
||||||
|
|
||||||
|
parts := strings.Split(pref, ",")
|
||||||
|
|
||||||
|
include := []string{}
|
||||||
|
exclude := map[string]bool{}
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(part, "-") {
|
||||||
|
// Exclusion
|
||||||
|
exclude[strings.TrimPrefix(part, "-")] = true
|
||||||
|
} else {
|
||||||
|
include = append(include, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only available crew members
|
||||||
|
result := []string{}
|
||||||
|
for _, name := range include {
|
||||||
|
if exclude[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if this crew exists
|
||||||
|
for _, avail := range available {
|
||||||
|
if avail == name {
|
||||||
|
result = append(result, name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCrewSession starts a crew session.
|
||||||
|
func ensureCrewSession(t *tmux.Tmux, sessionName, crewPath, rigName, crewName string) error {
|
||||||
|
// Create session in crew directory
|
||||||
|
if err := t.NewSession(sessionName, crewPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment
|
||||||
|
bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName)
|
||||||
|
_ = t.SetEnvironment(sessionName, "GT_ROLE", "crew")
|
||||||
|
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
|
||||||
|
_ = t.SetEnvironment(sessionName, "GT_CREW", crewName)
|
||||||
|
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
|
||||||
|
|
||||||
|
// Apply theme (use rig-based theme)
|
||||||
|
theme := tmux.AssignTheme(rigName)
|
||||||
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Crew", crewName)
|
||||||
|
|
||||||
|
// Launch Claude
|
||||||
|
claudeCmd := fmt.Sprintf(`export GT_ROLE=crew GT_RIG=%s GT_CREW=%s BD_ACTOR=%s && claude --dangerously-skip-permissions`, rigName, crewName, bdActor)
|
||||||
|
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPolecatsWithHooks starts polecats that have hook files (work attached).
|
||||||
|
// Returns list of started polecat names and map of errors.
|
||||||
|
func startPolecatsWithHooks(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) {
|
||||||
|
started := []string{}
|
||||||
|
errors := map[string]error{}
|
||||||
|
|
||||||
|
rigPath := filepath.Join(townRoot, rigName)
|
||||||
|
polecatsDir := filepath.Join(rigPath, "polecats")
|
||||||
|
|
||||||
|
// List polecat directories
|
||||||
|
entries, err := os.ReadDir(polecatsDir)
|
||||||
|
if err != nil {
|
||||||
|
// No polecats directory
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
polecatName := entry.Name()
|
||||||
|
polecatPath := filepath.Join(polecatsDir, polecatName)
|
||||||
|
|
||||||
|
// Check if this polecat has a hook file
|
||||||
|
agentID := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
|
||||||
|
hookPath := filepath.Join(polecatPath, ".beads", wisp.HookFilename(agentID))
|
||||||
|
|
||||||
|
hookData, err := os.ReadFile(hookPath)
|
||||||
|
if err != nil {
|
||||||
|
// No hook file - skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hook has work
|
||||||
|
var hook wisp.SlungWork
|
||||||
|
if err := json.Unmarshal(hookData, &hook); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hook.BeadID == "" {
|
||||||
|
// Empty hook - skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// This polecat has work - start it
|
||||||
|
sessionName := fmt.Sprintf("gt-%s-polecat-%s", rigName, polecatName)
|
||||||
|
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
|
if err != nil {
|
||||||
|
errors[polecatName] = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
started = append(started, polecatName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the polecat
|
||||||
|
if err := ensurePolecatSession(t, sessionName, polecatPath, rigName, polecatName); err != nil {
|
||||||
|
errors[polecatName] = err
|
||||||
|
} else {
|
||||||
|
started = append(started, polecatName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return started, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePolecatSession starts a polecat session.
|
||||||
|
func ensurePolecatSession(t *tmux.Tmux, sessionName, polecatPath, rigName, polecatName string) error {
|
||||||
|
// Create session in polecat directory
|
||||||
|
if err := t.NewSession(sessionName, polecatPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment
|
||||||
|
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
|
||||||
|
_ = t.SetEnvironment(sessionName, "GT_ROLE", "polecat")
|
||||||
|
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
|
||||||
|
_ = t.SetEnvironment(sessionName, "GT_POLECAT", polecatName)
|
||||||
|
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
|
||||||
|
|
||||||
|
// Apply theme (use rig-based theme)
|
||||||
|
theme := tmux.AssignTheme(rigName)
|
||||||
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Polecat", polecatName)
|
||||||
|
|
||||||
|
// Launch Claude
|
||||||
|
claudeCmd := fmt.Sprintf(`export GT_ROLE=polecat GT_RIG=%s GT_POLECAT=%s BD_ACTOR=%s && claude --dangerously-skip-permissions`, rigName, polecatName, bdActor)
|
||||||
|
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ type RigSettings struct {
|
|||||||
MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"` // merge queue settings
|
MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"` // merge queue settings
|
||||||
Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings
|
Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings
|
||||||
Namepool *NamepoolConfig `json:"namepool,omitempty"` // polecat name pool settings
|
Namepool *NamepoolConfig `json:"namepool,omitempty"` // polecat name pool settings
|
||||||
|
Crew *CrewConfig `json:"crew,omitempty"` // crew startup settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrewConfig represents crew workspace settings for a rig.
|
||||||
|
type CrewConfig struct {
|
||||||
|
// Startup is a natural language instruction for which crew to start on boot.
|
||||||
|
// Interpreted by AI during startup. Examples:
|
||||||
|
// "max" - start only max
|
||||||
|
// "joe and max" - start joe and max
|
||||||
|
// "all" - start all crew members
|
||||||
|
// "pick one" - start any one crew member
|
||||||
|
// "none" - don't auto-start any crew
|
||||||
|
// "max, but not emma" - start max, skip emma
|
||||||
|
// If empty, defaults to starting no crew automatically.
|
||||||
|
Startup string `json:"startup,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThemeConfig represents tmux theme settings for a rig.
|
// ThemeConfig represents tmux theme settings for a rig.
|
||||||
|
|||||||
Reference in New Issue
Block a user