feat(spawn): implement ephemeral molecule bonding

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-21 16:17:32 -08:00
parent 4761d09b52
commit 8bde425bc5

View File

@@ -2,8 +2,11 @@ package cmd
import ( import (
"bytes" "bytes"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -247,10 +250,11 @@ func runSpawn(cmd *cobra.Command, args []string) error {
// Handle molecule instantiation if specified // Handle molecule instantiation if specified
if spawnMolecule != "" { if spawnMolecule != "" {
b := beads.New(beadsPath) // Use main beads to get the molecule template and parent issue
mainBeads := beads.New(beadsPath)
// Get the molecule // Get the molecule template from main beads
mol, err := b.Show(spawnMolecule) mol, err := mainBeads.Show(spawnMolecule)
if err != nil { if err != nil {
return fmt.Errorf("getting molecule %s: %w", spawnMolecule, err) 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) return fmt.Errorf("invalid molecule: %w", err)
} }
// Get the parent issue // Get the parent issue from main beads
parent, err := b.Show(spawnIssue) parent, err := mainBeads.Show(spawnIssue)
if err != nil { if err != nil {
return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err) return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err)
} }
// Instantiate the molecule // Generate ephemeral instance ID
fmt.Printf("Instantiating molecule %s on %s...\n", spawnMolecule, spawnIssue) ephInstanceID, err := generateEphemeralInstanceID()
steps, err := b.InstantiateMolecule(mol, parent, beads.InstantiateOptions{})
if err != nil { 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 { for _, step := range steps {
fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title) 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)") 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{ moleculeCtx = &MoleculeContext{
MoleculeID: spawnMolecule, MoleculeID: spawnMolecule,
RootIssueID: spawnIssue, // Original issue is the molecule root RootIssueID: spawnIssue, // Original source issue in main beads
TotalSteps: len(steps), TotalSteps: len(steps),
StepNumber: stepNumber, StepNumber: stepNumber,
EphemeralInstanceID: ephInstanceID,
} }
// Switch to spawning on the first ready step // 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) 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 // Get or create issue
@@ -594,10 +630,11 @@ func buildSpawnContext(issue *BeadsIssue, message string) string {
// MoleculeContext contains information about a molecule workflow assignment. // MoleculeContext contains information about a molecule workflow assignment.
type MoleculeContext struct { type MoleculeContext struct {
MoleculeID string // The molecule template ID MoleculeID string // The molecule template ID
RootIssueID string // The parent issue (molecule root) RootIssueID string // The parent issue (molecule root)
TotalSteps int // Total number of steps in the molecule TotalSteps int // Total number of steps in the molecule
StepNumber int // Which step this is (1-indexed) 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. // 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 issue != nil {
if moleculeCtx != 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 { } else {
subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title) subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title)
} }
@@ -635,9 +677,21 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string,
if moleculeCtx != nil { if moleculeCtx != nil {
body.WriteString("\n## Molecule Workflow\n") 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("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(fmt.Sprintf("Source issue: %s\n", moleculeCtx.RootIssueID))
body.WriteString("**IMPORTANT**: This is part of a molecule workflow. After completing this step:\n") if moleculeCtx.EphemeralInstanceID != "" {
body.WriteString("1. Run `bd close " + issue.ID + "`\n") 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 <step-id>`\n")
}
body.WriteString("2. Run `bd ready --parent " + moleculeCtx.RootIssueID + "` to find next ready steps\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("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") 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, 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
}