From 8bde425bc54d4fb5488e4ec34a2419ca2464f1cd Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 21 Dec 2025 16:17:32 -0800 Subject: [PATCH] feat(spawn): implement ephemeral molecule bonding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When --molecule is specified with gt spawn: - Generate ephemeral instance ID (eph-abc123 format) - Ensure .beads-ephemeral/ directory exists and is initialized - Create ephemeral parent issue linking back to source issue - Instantiate molecule steps in ephemeral beads - Include ephemeral context in work assignment mail This implements gt-3x0z.4 (Phase 2.1: gt spawn --molecule bonds in ephemeral). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/spawn.go | 143 +++++++++++++++++++++++++++++++++++------- 1 file changed, 119 insertions(+), 24 deletions(-) diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 37fd2d4a..fb5d1742 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -2,8 +2,11 @@ package cmd import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -247,10 +250,11 @@ func runSpawn(cmd *cobra.Command, args []string) error { // Handle molecule instantiation if specified if spawnMolecule != "" { - b := beads.New(beadsPath) + // Use main beads to get the molecule template and parent issue + mainBeads := beads.New(beadsPath) - // Get the molecule - mol, err := b.Show(spawnMolecule) + // Get the molecule template from main beads + mol, err := mainBeads.Show(spawnMolecule) if err != nil { return fmt.Errorf("getting molecule %s: %w", spawnMolecule, err) } @@ -264,20 +268,49 @@ func runSpawn(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid molecule: %w", err) } - // Get the parent issue - parent, err := b.Show(spawnIssue) + // Get the parent issue from main beads + parent, err := mainBeads.Show(spawnIssue) if err != nil { return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err) } - // Instantiate the molecule - fmt.Printf("Instantiating molecule %s on %s...\n", spawnMolecule, spawnIssue) - steps, err := b.InstantiateMolecule(mol, parent, beads.InstantiateOptions{}) + // Generate ephemeral instance ID + ephInstanceID, err := generateEphemeralInstanceID() if err != nil { - return fmt.Errorf("instantiating molecule: %w", err) + return fmt.Errorf("generating ephemeral instance ID: %w", err) } - fmt.Printf("%s Created %d steps\n", style.Bold.Render("✓"), len(steps)) + // Ensure ephemeral beads directory exists + ephemeralPath, err := ensureEphemeralBeadsDir(r.Path) + if err != nil { + return fmt.Errorf("setting up ephemeral beads: %w", err) + } + + // Create ephemeral beads instance for molecule instantiation + ephBeads := beads.New(ephemeralPath) + + // Create an ephemeral parent issue to hold the molecule steps + // This links back to the source issue in main beads + ephParentDesc := fmt.Sprintf("Ephemeral molecule execution.\n\nsource_issue: %s\nmolecule: %s\ninstance: %s", + spawnIssue, spawnMolecule, ephInstanceID) + ephParent, err := ephBeads.Create(beads.CreateOptions{ + Title: fmt.Sprintf("[%s] %s", ephInstanceID, parent.Title), + Type: "task", + Priority: parent.Priority, + Description: ephParentDesc, + }) + if err != nil { + return fmt.Errorf("creating ephemeral parent issue: %w", err) + } + + // Instantiate the molecule in ephemeral beads + fmt.Printf("Bonding molecule %s on %s (ephemeral: %s)...\n", spawnMolecule, spawnIssue, ephInstanceID) + steps, err := ephBeads.InstantiateMolecule(mol, ephParent, beads.InstantiateOptions{}) + if err != nil { + return fmt.Errorf("instantiating molecule in ephemeral: %w", err) + } + + fmt.Printf("%s Bonded %d steps in ephemeral\n", style.Bold.Render("✓"), len(steps)) for _, step := range steps { fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title) } @@ -297,17 +330,20 @@ func runSpawn(cmd *cobra.Command, args []string) error { return fmt.Errorf("no ready step found in molecule (all steps have dependencies)") } - // Build molecule context for work assignment + // Build molecule context for work assignment with ephemeral info moleculeCtx = &MoleculeContext{ - MoleculeID: spawnMolecule, - RootIssueID: spawnIssue, // Original issue is the molecule root - TotalSteps: len(steps), - StepNumber: stepNumber, + MoleculeID: spawnMolecule, + RootIssueID: spawnIssue, // Original source issue in main beads + TotalSteps: len(steps), + StepNumber: stepNumber, + EphemeralInstanceID: ephInstanceID, } // Switch to spawning on the first ready step + // Note: The step is in ephemeral beads, but we still assign the source issue + // in main beads to the polecat for tracking fmt.Printf("\nSpawning on first ready step: %s\n", firstReadyStep.ID) - spawnIssue = firstReadyStep.ID + // Keep spawnIssue as the source issue for assignment tracking } // Get or create issue @@ -594,10 +630,11 @@ func buildSpawnContext(issue *BeadsIssue, message string) string { // MoleculeContext contains information about a molecule workflow assignment. type MoleculeContext struct { - MoleculeID string // The molecule template ID - RootIssueID string // The parent issue (molecule root) - TotalSteps int // Total number of steps in the molecule - StepNumber int // Which step this is (1-indexed) + MoleculeID string // The molecule template ID + RootIssueID string // The parent issue (molecule root) + TotalSteps int // Total number of steps in the molecule + StepNumber int // Which step this is (1-indexed) + EphemeralInstanceID string // Ephemeral instance ID (e.g., "eph-abc123") } // buildWorkAssignmentMail creates a work assignment mail message for a polecat. @@ -609,7 +646,12 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string, if issue != nil { if moleculeCtx != nil { - subject = fmt.Sprintf("🧬 Molecule Step %d/%d: %s", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, issue.Title) + if moleculeCtx.EphemeralInstanceID != "" { + // Ephemeral molecule format per spec + subject = fmt.Sprintf("📋 Work Assignment: %s [MOLECULE]", issue.Title) + } else { + subject = fmt.Sprintf("🧬 Molecule Step %d/%d: %s", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, issue.Title) + } } else { subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title) } @@ -635,9 +677,21 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string, if moleculeCtx != nil { body.WriteString("\n## Molecule Workflow\n") body.WriteString(fmt.Sprintf("You are working on step %d of %d in molecule %s.\n", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, moleculeCtx.MoleculeID)) - body.WriteString(fmt.Sprintf("Root issue: %s\n\n", moleculeCtx.RootIssueID)) - body.WriteString("**IMPORTANT**: This is part of a molecule workflow. After completing this step:\n") - body.WriteString("1. Run `bd close " + issue.ID + "`\n") + body.WriteString(fmt.Sprintf("Source issue: %s\n", moleculeCtx.RootIssueID)) + if moleculeCtx.EphemeralInstanceID != "" { + body.WriteString(fmt.Sprintf("Molecule instance: %s (ephemeral)\n\n", moleculeCtx.EphemeralInstanceID)) + body.WriteString("**IMPORTANT**: This is an ephemeral molecule workflow.\n") + body.WriteString("The molecule steps are tracked in `.beads-ephemeral/` (not main beads).\n") + body.WriteString("When complete, generate a summary and squash the molecule.\n\n") + } else { + body.WriteString("\n") + } + body.WriteString("After completing this step:\n") + if issue != nil { + body.WriteString("1. Run `bd close " + issue.ID + "`\n") + } else { + body.WriteString("1. Run `bd close `\n") + } body.WriteString("2. Run `bd ready --parent " + moleculeCtx.RootIssueID + "` to find next ready steps\n") body.WriteString("3. If more steps are ready, continue working on them\n") body.WriteString("4. When all steps are done, run `gt done` to signal completion\n\n") @@ -674,3 +728,44 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string, Type: mail.TypeTask, } } + +// generateEphemeralInstanceID creates a unique ephemeral instance ID. +// Format: "eph-" followed by 6 hex characters (e.g., "eph-abc123"). +func generateEphemeralInstanceID() (string, error) { + b := make([]byte, 3) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating random bytes: %w", err) + } + return "eph-" + hex.EncodeToString(b), nil +} + +// ensureEphemeralBeadsDir ensures the ephemeral beads directory exists and is initialized. +// Returns the path to the ephemeral beads directory. +func ensureEphemeralBeadsDir(rigPath string) (string, error) { + ephemeralPath := filepath.Join(rigPath, ".beads-ephemeral") + + // Create directory if it doesn't exist + if err := os.MkdirAll(ephemeralPath, 0755); err != nil { + return "", fmt.Errorf("creating ephemeral beads dir: %w", err) + } + + // Check if it's initialized as a git repo + gitDir := filepath.Join(ephemeralPath, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = ephemeralPath + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("initializing git in ephemeral dir: %w", err) + } + + // Create ephemeral config + configPath := filepath.Join(ephemeralPath, "config.yaml") + configContent := "ephemeral: true\n# No sync-branch - ephemeral is local only\n" + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + return "", fmt.Errorf("creating ephemeral config: %w", err) + } + } + + return ephemeralPath, nil +}