package cmd import ( "fmt" "os" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/checkpoint" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) var checkpointCmd = &cobra.Command{ Use: "checkpoint", GroupID: GroupDiag, Short: "Manage session checkpoints for crash recovery", Long: `Manage checkpoints for polecat session crash recovery. Checkpoints capture the current work state so that if a session crashes, the next session can resume from where it left off. Checkpoint data includes: - Current molecule and step - Hooked bead - Modified files list - Git branch and last commit - Timestamp Checkpoints are stored in .polecat-checkpoint.json in the polecat directory.`, } var checkpointWriteCmd = &cobra.Command{ Use: "write", Short: "Write a checkpoint of current session state", Long: `Capture and write the current session state to a checkpoint file. This is typically called: - After closing a molecule step - Periodically during long work sessions - Before handoff to another session The checkpoint captures git state, molecule progress, and hooked work.`, RunE: runCheckpointWrite, } var checkpointReadCmd = &cobra.Command{ Use: "read", Short: "Read and display the current checkpoint", Long: `Read and display the checkpoint file if one exists.`, RunE: runCheckpointRead, } var checkpointClearCmd = &cobra.Command{ Use: "clear", Short: "Clear the checkpoint file", Long: `Remove the checkpoint file. Use after work is complete or checkpoint is no longer needed.`, RunE: runCheckpointClear, } var ( checkpointNotes string checkpointMolecule string checkpointStep string ) func init() { checkpointCmd.AddCommand(checkpointWriteCmd) checkpointCmd.AddCommand(checkpointReadCmd) checkpointCmd.AddCommand(checkpointClearCmd) checkpointWriteCmd.Flags().StringVar(&checkpointNotes, "notes", "", "Add notes to the checkpoint") checkpointWriteCmd.Flags().StringVar(&checkpointMolecule, "molecule", "", "Override molecule ID (auto-detected if not specified)") checkpointWriteCmd.Flags().StringVar(&checkpointStep, "step", "", "Override step ID (auto-detected if not specified)") rootCmd.AddCommand(checkpointCmd) } func runCheckpointWrite(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } // Detect role context townRoot, err := workspace.FindFromCwd() if err != nil || townRoot == "" { return fmt.Errorf("not in a Gas Town workspace") } roleInfo, err := GetRoleWithContext(cwd, townRoot) if err != nil { return fmt.Errorf("detecting role: %w", err) } // Only polecats and crew workers use checkpoints if roleInfo.Role != RolePolecat && roleInfo.Role != RoleCrew { fmt.Printf("%s Checkpoints only apply to polecats and crew workers\n", style.Dim.Render("○")) return nil } // Capture current state cp, err := checkpoint.Capture(cwd) if err != nil { return fmt.Errorf("capturing checkpoint: %w", err) } // Add notes if provided if checkpointNotes != "" { cp.WithNotes(checkpointNotes) } // Try to detect molecule context if not overridden if checkpointMolecule == "" || checkpointStep == "" { moleculeID, stepID, stepTitle := detectMoleculeContext(cwd, roleInfo) if checkpointMolecule == "" { checkpointMolecule = moleculeID } if checkpointStep == "" { checkpointStep = stepID } if stepTitle != "" { cp.WithMolecule(checkpointMolecule, checkpointStep, stepTitle) } } // Add molecule context if checkpointMolecule != "" { cp.WithMolecule(checkpointMolecule, checkpointStep, "") } // Detect hooked bead hookedBead := detectHookedBead(cwd, roleInfo) if hookedBead != "" { cp.WithHookedBead(hookedBead) } // Write checkpoint if err := checkpoint.Write(cwd, cp); err != nil { return fmt.Errorf("writing checkpoint: %w", err) } fmt.Printf("%s Checkpoint written\n", style.Bold.Render("✓")) fmt.Printf(" %s\n", cp.Summary()) return nil } func runCheckpointRead(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } cp, err := checkpoint.Read(cwd) if err != nil { return fmt.Errorf("reading checkpoint: %w", err) } if cp == nil { fmt.Printf("%s No checkpoint exists\n", style.Dim.Render("○")) return nil } fmt.Printf("%s\n\n", style.Bold.Render("Checkpoint")) fmt.Printf("Timestamp: %s (%s ago)\n", cp.Timestamp.Format("2006-01-02 15:04:05"), cp.Age().Round(1)) if cp.MoleculeID != "" { fmt.Printf("Molecule: %s\n", cp.MoleculeID) } if cp.CurrentStep != "" { fmt.Printf("Step: %s\n", cp.CurrentStep) } if cp.StepTitle != "" { fmt.Printf("Step Title: %s\n", cp.StepTitle) } if cp.HookedBead != "" { fmt.Printf("Hooked Bead: %s\n", cp.HookedBead) } if cp.Branch != "" { fmt.Printf("Branch: %s\n", cp.Branch) } if cp.LastCommit != "" { fmt.Printf("Last Commit: %s\n", cp.LastCommit[:min(12, len(cp.LastCommit))]) } if len(cp.ModifiedFiles) > 0 { fmt.Printf("Modified Files: %d\n", len(cp.ModifiedFiles)) for _, f := range cp.ModifiedFiles { fmt.Printf(" - %s\n", f) } } if cp.Notes != "" { fmt.Printf("Notes: %s\n", cp.Notes) } if cp.SessionID != "" { fmt.Printf("Session ID: %s\n", cp.SessionID) } return nil } func runCheckpointClear(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } if err := checkpoint.Remove(cwd); err != nil { return fmt.Errorf("removing checkpoint: %w", err) } fmt.Printf("%s Checkpoint cleared\n", style.Bold.Render("✓")) return nil } // detectMoleculeContext tries to detect the current molecule and step from beads. func detectMoleculeContext(workDir string, ctx RoleInfo) (moleculeID, stepID, stepTitle string) { b := beads.New(workDir) // Get agent identity for query roleCtx := RoleContext{ Role: ctx.Role, Rig: ctx.Rig, Polecat: ctx.Polecat, } assignee := getAgentIdentity(roleCtx) if assignee == "" { return "", "", "" } // Find in-progress issues for this agent issues, err := b.List(beads.ListOptions{ Status: "in_progress", Assignee: assignee, Priority: -1, }) if err != nil || len(issues) == 0 { return "", "", "" } // Check for molecule metadata for _, issue := range issues { // Look for instantiated_from in description lines := strings.Split(issue.Description, "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "instantiated_from:") { moleculeID = strings.TrimSpace(strings.TrimPrefix(line, "instantiated_from:")) stepID = issue.ID stepTitle = issue.Title return moleculeID, stepID, stepTitle } } } return "", "", "" } // detectHookedBead finds the currently hooked bead for the agent. func detectHookedBead(workDir string, ctx RoleInfo) string { b := beads.New(workDir) // Get agent identity roleCtx := RoleContext{ Role: ctx.Role, Rig: ctx.Rig, Polecat: ctx.Polecat, } assignee := getAgentIdentity(roleCtx) if assignee == "" { return "" } // Find hooked beads for this agent hookedBeads, err := b.List(beads.ListOptions{ Status: beads.StatusHooked, Assignee: assignee, Priority: -1, }) if err != nil || len(hookedBeads) == 0 { return "" } return hookedBeads[0].ID } func min(a, b int) int { if a < b { return a } return b }