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 (
"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 <step-id>`\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
}