feat: Add gt mol step done for auto-continuing molecule steps (gt-5gq8r)

Implements the canonical way for polecats to complete molecule steps:
1. Closes the completed step (bd close)
2. Extracts molecule ID from step ID (gt-xxx.1 -> gt-xxx)
3. Finds next ready step (dependency-aware)
4. If next step: updates hook and respawns pane
5. If complete: burns hook and signals witness

This enables instant step transitions (~5-10s) vs waiting for witness
patrol cycles (minutes), and ensures the activity feed stays accurate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-26 13:15:17 -08:00
parent 74d59e3e13
commit 63bdf2ee33
2 changed files with 399 additions and 0 deletions

View File

@@ -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 <step-id>
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 <proto-id>",
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)

View File

@@ -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 <step-id>",
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 <step-id>)
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