From b7c7e7cbcd0f608a3e1e35f0b6693ed4a65bd216 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 21 Dec 2025 13:54:26 -0800 Subject: [PATCH] feat(mol): add bd mol squash command (bd-2vh3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Tier 2 of the ephemeral molecule cleanup workflow. The squash command compresses a molecule's ephemeral children into a single digest issue. Features: - Collects all ephemeral child issues of a molecule - Generates a structured digest with execution summary - Creates a non-ephemeral digest issue linked to the root - Optionally deletes ephemeral children (default behavior) - Supports --dry-run and --keep-children flags 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/mol_squash.go | 306 +++++++++++++++++++++++++++++++++++++++++++ cmd/bd/mol_test.go | 216 ++++++++++++++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 cmd/bd/mol_squash.go diff --git a/cmd/bd/mol_squash.go b/cmd/bd/mol_squash.go new file mode 100644 index 00000000..11b6502a --- /dev/null +++ b/cmd/bd/mol_squash.go @@ -0,0 +1,306 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +var molSquashCmd = &cobra.Command{ + Use: "squash ", + Short: "Compress molecule execution into a digest", + Long: `Squash a molecule's ephemeral children into a single digest issue. + +This command collects all ephemeral child issues of a molecule, generates +a summary digest, and optionally deletes the ephemeral children. + +The squash operation: + 1. Loads the molecule and all its children + 2. Filters to only ephemeral issues + 3. Generates a digest (summary of work done) + 4. Creates a non-ephemeral digest issue + 5. Deletes the ephemeral children (unless --keep-children) + +This is part of the ephemeral workflow: spawn creates ephemeral issues, +execution happens, squash compresses the trace into an outcome. + +Example: + bd mol squash bd-abc123 # Squash molecule children + bd mol squash bd-abc123 --dry-run # Preview what would be squashed + bd mol squash bd-abc123 --keep-children # Create digest but keep children`, + Args: cobra.ExactArgs(1), + Run: runMolSquash, +} + +// SquashResult holds the result of a squash operation +type SquashResult struct { + MoleculeID string `json:"molecule_id"` + DigestID string `json:"digest_id"` + SquashedIDs []string `json:"squashed_ids"` + SquashedCount int `json:"squashed_count"` + DeletedCount int `json:"deleted_count"` + KeptChildren bool `json:"kept_children"` +} + +func runMolSquash(cmd *cobra.Command, args []string) { + CheckReadonly("mol squash") + + ctx := rootCtx + + // mol squash requires direct store access + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: mol squash requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol squash %s ...\n", args[0]) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + keepChildren, _ := cmd.Flags().GetBool("keep-children") + + // Resolve molecule ID + moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err) + os.Exit(1) + } + + // Load the molecule subgraph + subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err) + os.Exit(1) + } + + // Filter to only ephemeral children (exclude root) + var ephemeralChildren []*types.Issue + for _, issue := range subgraph.Issues { + if issue.ID == subgraph.Root.ID { + continue // Skip root + } + if issue.Ephemeral { + ephemeralChildren = append(ephemeralChildren, issue) + } + } + + if len(ephemeralChildren) == 0 { + if jsonOutput { + outputJSON(SquashResult{ + MoleculeID: moleculeID, + SquashedCount: 0, + }) + } else { + fmt.Printf("No ephemeral children found for molecule %s\n", moleculeID) + } + return + } + + if dryRun { + fmt.Printf("\nDry run: would squash %d ephemeral children of %s\n\n", len(ephemeralChildren), moleculeID) + fmt.Printf("Root: %s\n", subgraph.Root.Title) + fmt.Printf("\nEphemeral children to squash:\n") + for _, issue := range ephemeralChildren { + status := string(issue.Status) + fmt.Printf(" - [%s] %s (%s)\n", status, issue.Title, issue.ID) + } + fmt.Printf("\nDigest preview:\n") + digest := generateDigest(subgraph.Root, ephemeralChildren) + // Show first 500 chars of digest + if len(digest) > 500 { + fmt.Printf("%s...\n", digest[:500]) + } else { + fmt.Printf("%s\n", digest) + } + if keepChildren { + fmt.Printf("\n--keep-children: children would NOT be deleted\n") + } else { + fmt.Printf("\nChildren would be deleted after digest creation.\n") + } + return + } + + // Perform the squash + result, err := squashMolecule(ctx, store, subgraph.Root, ephemeralChildren, keepChildren, actor) + if err != nil { + fmt.Fprintf(os.Stderr, "Error squashing molecule: %v\n", err) + os.Exit(1) + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + if jsonOutput { + outputJSON(result) + return + } + + fmt.Printf("%s Squashed molecule: %d children → 1 digest\n", ui.RenderPass("✓"), result.SquashedCount) + fmt.Printf(" Digest ID: %s\n", result.DigestID) + if result.DeletedCount > 0 { + fmt.Printf(" Deleted: %d ephemeral issues\n", result.DeletedCount) + } else if result.KeptChildren { + fmt.Printf(" Children preserved (--keep-children)\n") + } +} + +// generateDigest creates a summary from the molecule execution +// Tier 2: Simple concatenation of titles and descriptions +// Tier 3 (future): AI-powered summarization using Haiku +func generateDigest(root *types.Issue, children []*types.Issue) string { + var sb strings.Builder + + sb.WriteString("## Molecule Execution Summary\n\n") + sb.WriteString(fmt.Sprintf("**Molecule**: %s\n", root.Title)) + sb.WriteString(fmt.Sprintf("**Steps**: %d\n\n", len(children))) + + // Count completed vs other statuses + completed := 0 + inProgress := 0 + for _, c := range children { + switch c.Status { + case types.StatusClosed: + completed++ + case types.StatusInProgress: + inProgress++ + } + } + sb.WriteString(fmt.Sprintf("**Completed**: %d/%d\n", completed, len(children))) + if inProgress > 0 { + sb.WriteString(fmt.Sprintf("**In Progress**: %d\n", inProgress)) + } + sb.WriteString("\n---\n\n") + + // List each step with its outcome + sb.WriteString("### Steps\n\n") + for i, child := range children { + status := string(child.Status) + sb.WriteString(fmt.Sprintf("%d. **[%s]** %s\n", i+1, status, child.Title)) + if child.Description != "" { + // Include first 200 chars of description + desc := child.Description + if len(desc) > 200 { + desc = desc[:200] + "..." + } + sb.WriteString(fmt.Sprintf(" %s\n", desc)) + } + if child.CloseReason != "" { + sb.WriteString(fmt.Sprintf(" *Outcome: %s*\n", child.CloseReason)) + } + sb.WriteString("\n") + } + + return sb.String() +} + +// squashMolecule performs the squash operation +func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, children []*types.Issue, keepChildren bool, actorName string) (*SquashResult, error) { + if s == nil { + return nil, fmt.Errorf("no database connection") + } + + // Collect child IDs + childIDs := make([]string, len(children)) + for i, c := range children { + childIDs[i] = c.ID + } + + // Generate digest content + digestContent := generateDigest(root, children) + + // Create digest issue (non-ephemeral) + now := time.Now() + digestIssue := &types.Issue{ + Title: fmt.Sprintf("Digest: %s", root.Title), + Description: digestContent, + Status: types.StatusClosed, + CloseReason: fmt.Sprintf("Squashed from %d ephemeral steps", len(children)), + Priority: root.Priority, + IssueType: types.TypeTask, + Ephemeral: false, // Digest is permanent + ClosedAt: &now, + } + + result := &SquashResult{ + MoleculeID: root.ID, + SquashedIDs: childIDs, + SquashedCount: len(children), + KeptChildren: keepChildren, + } + + // Use transaction for atomicity + err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { + // Create digest issue + if err := tx.CreateIssue(ctx, digestIssue, actorName); err != nil { + return fmt.Errorf("failed to create digest issue: %w", err) + } + result.DigestID = digestIssue.ID + + // Link digest to root as parent-child + dep := &types.Dependency{ + IssueID: digestIssue.ID, + DependsOnID: root.ID, + Type: types.DepParentChild, + } + if err := tx.AddDependency(ctx, dep, actorName); err != nil { + return fmt.Errorf("failed to link digest to root: %w", err) + } + + return nil + }) + + if err != nil { + return nil, err + } + + // Delete ephemeral children (outside transaction for better error handling) + if !keepChildren { + deleted, err := deleteEphemeralChildren(ctx, s, childIDs) + if err != nil { + // Log but don't fail - digest was created successfully + fmt.Fprintf(os.Stderr, "Warning: failed to delete some children: %v\n", err) + } + result.DeletedCount = deleted + } + + return result, nil +} + +// deleteEphemeralChildren removes the ephemeral issues from the database +func deleteEphemeralChildren(ctx context.Context, s storage.Storage, ids []string) (int, error) { + // Type assert to SQLite storage for delete access + d, ok := s.(*sqlite.SQLiteStorage) + if !ok { + return 0, fmt.Errorf("delete not supported by this storage backend") + } + + deleted := 0 + var lastErr error + for _, id := range ids { + if err := d.DeleteIssue(ctx, id); err != nil { + lastErr = err + continue + } + deleted++ + } + + return deleted, lastErr +} + +func init() { + molSquashCmd.Flags().Bool("dry-run", false, "Preview what would be squashed") + molSquashCmd.Flags().Bool("keep-children", false, "Don't delete ephemeral children after squash") + + molCmd.AddCommand(molSquashCmd) +} diff --git a/cmd/bd/mol_test.go b/cmd/bd/mol_test.go index ec3415d4..794485b8 100644 --- a/cmd/bd/mol_test.go +++ b/cmd/bd/mol_test.go @@ -458,3 +458,219 @@ func TestBondMolMol(t *testing.T) { t.Errorf("Expected parent-child dependency for parallel bond, result: %+v", result2) } } + +func TestSquashMolecule(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Create a molecule (root issue) + root := &types.Issue{ + Title: "Test Molecule", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeEpic, + } + if err := s.CreateIssue(ctx, root, "test"); err != nil { + t.Fatalf("Failed to create root: %v", err) + } + + // Create ephemeral children + child1 := &types.Issue{ + Title: "Step 1: Design", + Description: "Design the architecture", + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + CloseReason: "Completed design", + } + child2 := &types.Issue{ + Title: "Step 2: Implement", + Description: "Build the feature", + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + CloseReason: "Code merged", + } + + if err := s.CreateIssue(ctx, child1, "test"); err != nil { + t.Fatalf("Failed to create child1: %v", err) + } + if err := s.CreateIssue(ctx, child2, "test"); err != nil { + t.Fatalf("Failed to create child2: %v", err) + } + + // Add parent-child dependencies + if err := s.AddDependency(ctx, &types.Dependency{ + IssueID: child1.ID, + DependsOnID: root.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add child1 dependency: %v", err) + } + if err := s.AddDependency(ctx, &types.Dependency{ + IssueID: child2.ID, + DependsOnID: root.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add child2 dependency: %v", err) + } + + // Test squash with keep-children + children := []*types.Issue{child1, child2} + result, err := squashMolecule(ctx, s, root, children, true, "test") + if err != nil { + t.Fatalf("squashMolecule failed: %v", err) + } + + if result.SquashedCount != 2 { + t.Errorf("SquashedCount = %d, want 2", result.SquashedCount) + } + if result.DeletedCount != 0 { + t.Errorf("DeletedCount = %d, want 0 (keep-children)", result.DeletedCount) + } + if !result.KeptChildren { + t.Error("KeptChildren should be true") + } + + // Verify digest was created + digest, err := s.GetIssue(ctx, result.DigestID) + if err != nil { + t.Fatalf("Failed to get digest: %v", err) + } + if digest.Ephemeral { + t.Error("Digest should NOT be ephemeral") + } + if digest.Status != types.StatusClosed { + t.Errorf("Digest status = %v, want closed", digest.Status) + } + if !strings.Contains(digest.Description, "Step 1: Design") { + t.Error("Digest should contain child titles") + } + if !strings.Contains(digest.Description, "Completed design") { + t.Error("Digest should contain close reasons") + } + + // Children should still exist + c1, err := s.GetIssue(ctx, child1.ID) + if err != nil || c1 == nil { + t.Error("Child1 should still exist with keep-children") + } +} + +func TestSquashMoleculeWithDelete(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Create a molecule with ephemeral children + root := &types.Issue{ + Title: "Delete Test Molecule", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeEpic, + } + if err := s.CreateIssue(ctx, root, "test"); err != nil { + t.Fatalf("Failed to create root: %v", err) + } + + child := &types.Issue{ + Title: "Ephemeral Step", + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + } + if err := s.CreateIssue(ctx, child, "test"); err != nil { + t.Fatalf("Failed to create child: %v", err) + } + if err := s.AddDependency(ctx, &types.Dependency{ + IssueID: child.ID, + DependsOnID: root.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + // Squash with delete (keepChildren=false) + result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, false, "test") + if err != nil { + t.Fatalf("squashMolecule failed: %v", err) + } + + if result.DeletedCount != 1 { + t.Errorf("DeletedCount = %d, want 1", result.DeletedCount) + } + + // Child should be deleted + c, err := s.GetIssue(ctx, child.ID) + if err == nil && c != nil { + t.Error("Child should have been deleted") + } + + // Digest should exist + digest, err := s.GetIssue(ctx, result.DigestID) + if err != nil || digest == nil { + t.Error("Digest should exist after squash") + } +} + +func TestGenerateDigest(t *testing.T) { + root := &types.Issue{ + Title: "Test Molecule", + } + children := []*types.Issue{ + { + Title: "Step 1", + Description: "First step description", + Status: types.StatusClosed, + CloseReason: "Done", + }, + { + Title: "Step 2", + Description: "Second step description that is longer", + Status: types.StatusInProgress, + }, + } + + digest := generateDigest(root, children) + + // Verify structure + if !strings.Contains(digest, "## Molecule Execution Summary") { + t.Error("Digest should have summary header") + } + if !strings.Contains(digest, "Test Molecule") { + t.Error("Digest should contain molecule title") + } + if !strings.Contains(digest, "**Steps**: 2") { + t.Error("Digest should show step count") + } + if !strings.Contains(digest, "**Completed**: 1/2") { + t.Error("Digest should show completion stats") + } + if !strings.Contains(digest, "**In Progress**: 1") { + t.Error("Digest should show in-progress count") + } + if !strings.Contains(digest, "Step 1") { + t.Error("Digest should list step titles") + } + if !strings.Contains(digest, "*Outcome: Done*") { + t.Error("Digest should include close reasons") + } +}