diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index e298fef4..25ba5f70 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -305,6 +305,23 @@ a permanent (but compact) record.`, RunE: runMoleculeSquash, } +var moleculeStepCmd = &cobra.Command{ + Use: "step", + Short: "Molecule step operations", + Long: `Commands for working with molecule steps. + +A molecule is a DAG of steps. Each step is a beads issue with the molecule root +as its parent. Steps can have dependencies on other steps. + +When a polecat is working on a molecule, it processes one step at a time: +1. Work on the current step +2. When done: gt mol step done +3. System auto-continues to next ready step + +IMPORTANT: Always use 'gt mol step done' to complete steps. Do not manually +close steps with 'bd close' - that skips the auto-continuation logic.`, +} + var moleculeBondCmd = &cobra.Command{ Use: "bond ", Short: "Dynamically bond a child molecule to a running parent", @@ -381,6 +398,10 @@ func init() { moleculeBondCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") moleculeBondCmd.MarkFlagRequired("parent") + // Add step subcommand with its children + moleculeStepCmd.AddCommand(moleculeStepDoneCmd) + moleculeCmd.AddCommand(moleculeStepCmd) + // Add subcommands moleculeCmd.AddCommand(moleculeStatusCmd) moleculeCmd.AddCommand(moleculeCurrentCmd) diff --git a/internal/cmd/molecule_step.go b/internal/cmd/molecule_step.go new file mode 100644 index 00000000..25acc182 --- /dev/null +++ b/internal/cmd/molecule_step.go @@ -0,0 +1,378 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/wisp" + "github.com/steveyegge/gastown/internal/workspace" +) + +// moleculeStepDoneCmd is the "gt mol step done" command. +var moleculeStepDoneCmd = &cobra.Command{ + Use: "done ", + Short: "Complete step and auto-continue to next", + Long: `Complete a molecule step and automatically continue to the next ready step. + +This command handles the step-to-step transition for polecats: + +1. Closes the completed step (bd close ) +2. Extracts the molecule ID from the step +3. Finds the next ready step (dependency-aware) +4. If next step exists: + - Updates the hook to point to the next step + - Respawns the pane for a fresh session +5. If molecule complete: + - Clears the hook + - Sends POLECAT_DONE to witness + - Exits the session + +IMPORTANT: This is the canonical way to complete molecule steps. Do NOT manually +close steps with 'bd close' - it skips the auto-continuation logic. + +Example: + gt mol step done gt-abc.1 # Complete step 1 of molecule gt-abc`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeStepDone, +} + +var ( + moleculeStepDryRun bool +) + +func init() { + moleculeStepDoneCmd.Flags().BoolVarP(&moleculeStepDryRun, "dry-run", "n", false, "Show what would be done without executing") + moleculeStepDoneCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") +} + +// StepDoneResult is the result of a step done operation. +type StepDoneResult struct { + StepID string `json:"step_id"` + MoleculeID string `json:"molecule_id"` + StepClosed bool `json:"step_closed"` + NextStepID string `json:"next_step_id,omitempty"` + NextStepTitle string `json:"next_step_title,omitempty"` + Complete bool `json:"complete"` + Action string `json:"action"` // "continue", "done", "no_more_ready" +} + +func runMoleculeStepDone(cmd *cobra.Command, args []string) error { + stepID := args[0] + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + // Find town root + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding workspace: %w", err) + } + if townRoot == "" { + return fmt.Errorf("not in a Gas Town workspace") + } + + // Find beads directory + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Step 1: Verify the step exists + step, err := b.Show(stepID) + if err != nil { + return fmt.Errorf("step not found: %w", err) + } + + // Step 2: Extract molecule ID from step ID (gt-xxx.1 -> gt-xxx) + moleculeID := extractMoleculeIDFromStep(stepID) + if moleculeID == "" { + return fmt.Errorf("cannot extract molecule ID from step %s (expected format: gt-xxx.N)", stepID) + } + + result := StepDoneResult{ + StepID: stepID, + MoleculeID: moleculeID, + } + + // Step 3: Close the step + if moleculeStepDryRun { + fmt.Printf("[dry-run] Would close step: %s\n", stepID) + result.StepClosed = true + } else { + if err := b.Close(stepID); err != nil { + return fmt.Errorf("closing step: %w", err) + } + result.StepClosed = true + fmt.Printf("%s Closed step %s: %s\n", style.Bold.Render("✓"), stepID, step.Title) + } + + // Step 4: Find the next ready step + nextStep, allComplete, err := findNextReadyStep(b, moleculeID) + if err != nil { + return fmt.Errorf("finding next step: %w", err) + } + + if allComplete { + result.Complete = true + result.Action = "done" + } else if nextStep != nil { + result.NextStepID = nextStep.ID + result.NextStepTitle = nextStep.Title + result.Action = "continue" + } else { + // There are more steps but none are ready (blocked on dependencies) + result.Action = "no_more_ready" + } + + // JSON output + if moleculeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + // Step 5: Handle next action + switch result.Action { + case "continue": + return handleStepContinue(cwd, townRoot, workDir, nextStep, moleculeStepDryRun) + + case "done": + return handleMoleculeComplete(cwd, townRoot, moleculeID, moleculeStepDryRun) + + case "no_more_ready": + fmt.Printf("\n%s All remaining steps are blocked - waiting on dependencies\n", + style.Dim.Render("ℹ")) + fmt.Printf("Run 'gt mol progress %s' to see blocked steps\n", moleculeID) + return nil + } + + return nil +} + +// extractMoleculeIDFromStep extracts the molecule ID from a step ID. +// Step IDs have format: mol-id.N where N is the step number. +// Examples: +// gt-abc.1 -> gt-abc +// gt-xyz.3 -> gt-xyz +// bd-mol-abc.2 -> bd-mol-abc +func extractMoleculeIDFromStep(stepID string) string { + // Find the last dot + lastDot := strings.LastIndex(stepID, ".") + if lastDot == -1 { + return "" // No dot - not a step ID format + } + + // Check if what's after the dot is a number (step suffix) + suffix := stepID[lastDot+1:] + for _, c := range suffix { + if c < '0' || c > '9' { + return "" // Not a numeric suffix + } + } + + return stepID[:lastDot] +} + +// findNextReadyStep finds the next ready step in a molecule. +// Returns (nextStep, allComplete, error). +// If all steps are complete, returns (nil, true, nil). +// If no steps are ready but some are blocked, returns (nil, false, nil). +func findNextReadyStep(b *beads.Beads, moleculeID string) (*beads.Issue, bool, error) { + // Get all children of the molecule + children, err := b.List(beads.ListOptions{ + Parent: moleculeID, + Status: "all", + Priority: -1, + }) + if err != nil { + return nil, false, fmt.Errorf("listing molecule steps: %w", err) + } + + if len(children) == 0 { + return nil, true, nil // No steps = complete + } + + // Build set of closed step IDs + closedIDs := make(map[string]bool) + var openSteps []*beads.Issue + + for _, child := range children { + if child.Status == "closed" { + closedIDs[child.ID] = true + } else { + openSteps = append(openSteps, child) + } + } + + // Check if all complete + if len(openSteps) == 0 { + return nil, true, nil + } + + // Find ready steps (open steps with all dependencies closed) + for _, step := range openSteps { + allDepsClosed := true + for _, depID := range step.DependsOn { + if !closedIDs[depID] { + allDepsClosed = false + break + } + } + + if len(step.DependsOn) == 0 || allDepsClosed { + return step, false, nil + } + } + + // No ready steps (all blocked) + return nil, false, nil +} + +// handleStepContinue handles continuing to the next step. +func handleStepContinue(cwd, townRoot, workDir string, nextStep *beads.Issue, dryRun bool) error { + fmt.Printf("\n%s Next step: %s\n", style.Bold.Render("→"), nextStep.ID) + fmt.Printf(" %s\n", nextStep.Title) + + // Detect agent identity + roleInfo, err := GetRoleWithContext(cwd, townRoot) + if err != nil { + return fmt.Errorf("detecting role: %w", err) + } + + roleCtx := RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } + agentID := buildAgentIdentity(roleCtx) + if agentID == "" { + return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role) + } + + // Get git root for hook files + gitRoot, err := getGitRoot() + if err != nil { + return fmt.Errorf("finding git root: %w", err) + } + + // Update the hook to point to the next step + sw := wisp.NewSlungWork(nextStep.ID, agentID) + sw.Subject = fmt.Sprintf("Step: %s", nextStep.Title) + sw.Context = fmt.Sprintf("Continuing molecule from step %s", nextStep.ID) + + if dryRun { + fmt.Printf("\n[dry-run] Would update hook to: %s\n", nextStep.ID) + fmt.Printf("[dry-run] Would respawn pane\n") + return nil + } + + if err := wisp.WriteSlungWork(gitRoot, agentID, sw); err != nil { + return fmt.Errorf("writing hook: %w", err) + } + + fmt.Printf("%s Hook updated for next step\n", style.Bold.Render("🪝")) + + // Respawn the pane + if !tmux.IsInsideTmux() { + // Not in tmux - just print next action + fmt.Printf("\n%s Not in tmux - start new session with 'gt prime'\n", + style.Dim.Render("ℹ")) + return nil + } + + pane := os.Getenv("TMUX_PANE") + if pane == "" { + return fmt.Errorf("TMUX_PANE not set") + } + + // Get current session for restart command + currentSession, err := getCurrentTmuxSession() + if err != nil { + return fmt.Errorf("getting session name: %w", err) + } + + restartCmd, err := buildRestartCommand(currentSession) + if err != nil { + return fmt.Errorf("building restart command: %w", err) + } + + fmt.Printf("\n%s Respawning for next step...\n", style.Bold.Render("🔄")) + + t := tmux.NewTmux() + + // Clear history before respawn + if err := t.ClearHistory(pane); err != nil { + // Non-fatal + fmt.Printf("%s Warning: could not clear history: %v\n", style.Dim.Render("⚠"), err) + } + + return t.RespawnPane(pane, restartCmd) +} + +// handleMoleculeComplete handles when a molecule is complete. +func handleMoleculeComplete(cwd, townRoot, moleculeID string, dryRun bool) error { + fmt.Printf("\n%s Molecule complete!\n", style.Bold.Render("🎉")) + + // Detect agent identity + roleInfo, err := GetRoleWithContext(cwd, townRoot) + if err != nil { + return fmt.Errorf("detecting role: %w", err) + } + + roleCtx := RoleContext{ + Role: roleInfo.Role, + Rig: roleInfo.Rig, + Polecat: roleInfo.Polecat, + TownRoot: townRoot, + WorkDir: cwd, + } + agentID := buildAgentIdentity(roleCtx) + + // Get git root for hook files + gitRoot, err := getGitRoot() + if err != nil { + return fmt.Errorf("finding git root: %w", err) + } + + if dryRun { + fmt.Printf("[dry-run] Would burn hook for %s\n", agentID) + fmt.Printf("[dry-run] Would send POLECAT_DONE to witness\n") + return nil + } + + // Burn the hook + if err := wisp.BurnHook(gitRoot, agentID); err != nil { + fmt.Printf("%s Warning: could not burn hook: %v\n", style.Dim.Render("⚠"), err) + } else { + fmt.Printf("%s Hook cleared\n", style.Bold.Render("✓")) + } + + // For polecats, use gt done to signal completion + if roleCtx.Role == RolePolecat { + fmt.Printf("%s Signaling completion to witness...\n", style.Bold.Render("📤")) + + doneCmd := exec.Command("gt", "done", "--exit", "DEFERRED") + doneCmd.Stdout = os.Stdout + doneCmd.Stderr = os.Stderr + return doneCmd.Run() + } + + // For other roles, just print completion message + fmt.Printf("\nMolecule %s is complete. Ready for next assignment.\n", moleculeID) + return nil +} + +// getGitRoot is defined in prime.go