From 39f8461914edc644d0a17ff952a88741a7930c57 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 21 Dec 2025 14:37:22 -0800 Subject: [PATCH] feat(mol): filter ephemeral issues from JSONL export (bd-687g) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ephemeral issues should never be exported to issues.jsonl. They exist only in SQLite and are shared via .beads/redirect pointers. This prevents "zombie" issues from resurrecting after mol squash deletes them. Changes: - Filter ephemeral issues in autoflush, export, and multirepo_export - Add --summary flag to bd mol squash for agent-provided summaries - Fix DeleteIssue to also remove comments (missing cascade) - Add tests for ephemeral filtering and comment deletion 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- cmd/bd/autoflush.go | 11 ++ cmd/bd/export.go | 10 ++ cmd/bd/mol_squash.go | 29 ++++- cmd/bd/mol_test.go | 135 +++++++++++++++++++- internal/storage/sqlite/delete_test.go | 51 ++++++++ internal/storage/sqlite/multirepo_export.go | 10 ++ internal/storage/sqlite/queries.go | 6 + 8 files changed, 245 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4afbdd81..b22285bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -754,7 +754,7 @@ history/ - ✅ Link discovered work with `discovered-from` dependencies - ✅ Run `gt mail inbox` from your cwd, not ~/gt - ✅ Store AI planning docs in `history/` directory -- ❌ Do NOT use `bd ready` or `bd list` to find work - the overseer directs your work +- ✅ Use `bd ready` to see unblocked work available for pickup - ❌ Do NOT create markdown TODO lists - ❌ Do NOT clutter repo root with planning documents diff --git a/cmd/bd/autoflush.go b/cmd/bd/autoflush.go index 39f37d3b..22df9d98 100644 --- a/cmd/bd/autoflush.go +++ b/cmd/bd/autoflush.go @@ -704,10 +704,21 @@ func flushToJSONLWithState(state flushState) { } // Convert map to slice (will be sorted by writeJSONLAtomic) + // Filter out ephemeral issues - they should never be exported to JSONL (bd-687g) + // Ephemeral issues exist only in SQLite and are shared via .beads/redirect, not JSONL. + // This prevents "zombie" issues that resurrect after mol squash deletes them. issues := make([]*types.Issue, 0, len(issueMap)) + ephemeralSkipped := 0 for _, issue := range issueMap { + if issue.Ephemeral { + ephemeralSkipped++ + continue + } issues = append(issues, issue) } + if ephemeralSkipped > 0 { + debug.Logf("auto-flush: filtered %d ephemeral issues from export", ephemeralSkipped) + } // Filter issues by prefix in multi-repo mode for non-primary repos (fixes GH #437) // In multi-repo mode, non-primary repos should only export issues that match diff --git a/cmd/bd/export.go b/cmd/bd/export.go index 57f19d1e..73b6ef21 100644 --- a/cmd/bd/export.go +++ b/cmd/bd/export.go @@ -346,6 +346,16 @@ Examples: } } + // Filter out ephemeral issues - they should never be exported to JSONL (bd-687g) + // Ephemeral issues exist only in SQLite and are shared via .beads/redirect, not JSONL. + filtered := make([]*types.Issue, 0, len(issues)) + for _, issue := range issues { + if !issue.Ephemeral { + filtered = append(filtered, issue) + } + } + issues = filtered + // Sort by ID for consistent output sort.Slice(issues, func(i, j int) bool { return issues[i].ID < issues[j].ID diff --git a/cmd/bd/mol_squash.go b/cmd/bd/mol_squash.go index 11b6502a..cc611dcb 100644 --- a/cmd/bd/mol_squash.go +++ b/cmd/bd/mol_squash.go @@ -30,13 +30,20 @@ The squash operation: 4. Creates a non-ephemeral digest issue 5. Deletes the ephemeral children (unless --keep-children) +AGENT INTEGRATION: +Use --summary to provide an AI-generated summary. This keeps bd as a pure +tool - the calling agent (Gas Town polecat, Claude Code, etc.) is responsible +for generating intelligent summaries. Without --summary, a basic concatenation +of child issue content is used. + 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 # Squash with auto-generated digest bd mol squash bd-abc123 --dry-run # Preview what would be squashed - bd mol squash bd-abc123 --keep-children # Create digest but keep children`, + bd mol squash bd-abc123 --keep-children # Create digest but keep children + bd mol squash bd-abc123 --summary "Agent-generated summary of work done"`, Args: cobra.ExactArgs(1), Run: runMolSquash, } @@ -69,6 +76,7 @@ func runMolSquash(cmd *cobra.Command, args []string) { dryRun, _ := cmd.Flags().GetBool("dry-run") keepChildren, _ := cmd.Flags().GetBool("keep-children") + summary, _ := cmd.Flags().GetString("summary") // Resolve molecule ID moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) @@ -132,7 +140,7 @@ func runMolSquash(cmd *cobra.Command, args []string) { } // Perform the squash - result, err := squashMolecule(ctx, store, subgraph.Root, ephemeralChildren, keepChildren, actor) + result, err := squashMolecule(ctx, store, subgraph.Root, ephemeralChildren, keepChildren, summary, actor) if err != nil { fmt.Fprintf(os.Stderr, "Error squashing molecule: %v\n", err) os.Exit(1) @@ -205,7 +213,10 @@ func generateDigest(root *types.Issue, children []*types.Issue) 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 summary is provided (non-empty), it's used as the digest content. +// Otherwise, generateDigest() creates a basic concatenation. +// This enables agents to provide AI-generated summaries while keeping bd as a pure tool. +func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, children []*types.Issue, keepChildren bool, summary string, actorName string) (*SquashResult, error) { if s == nil { return nil, fmt.Errorf("no database connection") } @@ -216,8 +227,13 @@ func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, c childIDs[i] = c.ID } - // Generate digest content - digestContent := generateDigest(root, children) + // Use agent-provided summary if available, otherwise generate basic digest + var digestContent string + if summary != "" { + digestContent = summary + } else { + digestContent = generateDigest(root, children) + } // Create digest issue (non-ephemeral) now := time.Now() @@ -301,6 +317,7 @@ func deleteEphemeralChildren(ctx context.Context, s storage.Storage, ids []strin 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") + molSquashCmd.Flags().String("summary", "", "Agent-provided summary (bypasses auto-generation)") molCmd.AddCommand(molSquashCmd) } diff --git a/cmd/bd/mol_test.go b/cmd/bd/mol_test.go index 794485b8..86ee7c71 100644 --- a/cmd/bd/mol_test.go +++ b/cmd/bd/mol_test.go @@ -527,7 +527,7 @@ func TestSquashMolecule(t *testing.T) { // Test squash with keep-children children := []*types.Issue{child1, child2} - result, err := squashMolecule(ctx, s, root, children, true, "test") + result, err := squashMolecule(ctx, s, root, children, true, "", "test") if err != nil { t.Fatalf("squashMolecule failed: %v", err) } @@ -609,7 +609,7 @@ func TestSquashMoleculeWithDelete(t *testing.T) { } // Squash with delete (keepChildren=false) - result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, false, "test") + result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, false, "", "test") if err != nil { t.Fatalf("squashMolecule failed: %v", err) } @@ -674,3 +674,134 @@ func TestGenerateDigest(t *testing.T) { t.Error("Digest should include close reasons") } } + +// TestSquashMoleculeWithAgentSummary verifies that agent-provided summaries are used +func TestSquashMoleculeWithAgentSummary(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 child + root := &types.Issue{ + Title: "Agent Summary Test", + 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", + Description: "This should NOT appear in digest", + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + CloseReason: "Done", + } + 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 agent-provided summary + agentSummary := "## AI-Generated Summary\n\nThe agent completed the task successfully." + result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, true, agentSummary, "test") + if err != nil { + t.Fatalf("squashMolecule failed: %v", err) + } + + // Verify digest uses agent summary, not auto-generated content + digest, err := s.GetIssue(ctx, result.DigestID) + if err != nil { + t.Fatalf("Failed to get digest: %v", err) + } + + if digest.Description != agentSummary { + t.Errorf("Digest should use agent summary.\nGot: %s\nWant: %s", digest.Description, agentSummary) + } + + // Verify auto-generated content is NOT present + if strings.Contains(digest.Description, "Ephemeral Step") { + t.Error("Digest should NOT contain auto-generated content when agent summary provided") + } +} + +// TestEphemeralFilteringFromExport verifies that ephemeral issues are filtered +// from JSONL export (bd-687g). Ephemeral issues should only exist in SQLite, +// not in issues.jsonl, to prevent "zombie" resurrection after mol squash. +func TestEphemeralFilteringFromExport(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 mix of ephemeral and non-ephemeral issues + normalIssue := &types.Issue{ + Title: "Normal Issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Ephemeral: false, + } + ephemeralIssue := &types.Issue{ + Title: "Ephemeral Issue", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + Ephemeral: true, + } + + if err := s.CreateIssue(ctx, normalIssue, "test"); err != nil { + t.Fatalf("Failed to create normal issue: %v", err) + } + if err := s.CreateIssue(ctx, ephemeralIssue, "test"); err != nil { + t.Fatalf("Failed to create ephemeral issue: %v", err) + } + + // Get all issues from DB - should include both + allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatalf("Failed to search issues: %v", err) + } + if len(allIssues) != 2 { + t.Fatalf("Expected 2 issues in DB, got %d", len(allIssues)) + } + + // Filter ephemeral issues (simulating export behavior) + exportableIssues := make([]*types.Issue, 0) + for _, issue := range allIssues { + if !issue.Ephemeral { + exportableIssues = append(exportableIssues, issue) + } + } + + // Should only have the non-ephemeral issue + if len(exportableIssues) != 1 { + t.Errorf("Expected 1 exportable issue, got %d", len(exportableIssues)) + } + if exportableIssues[0].ID != normalIssue.ID { + t.Errorf("Expected normal issue %s, got %s", normalIssue.ID, exportableIssues[0].ID) + } +} diff --git a/internal/storage/sqlite/delete_test.go b/internal/storage/sqlite/delete_test.go index c2c33c02..8f04cb40 100644 --- a/internal/storage/sqlite/delete_test.go +++ b/internal/storage/sqlite/delete_test.go @@ -236,6 +236,57 @@ func TestDeleteIssue(t *testing.T) { } } +// TestDeleteIssueWithComments verifies that DeleteIssue also removes comments (bd-687g) +func TestDeleteIssueWithComments(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + ctx := context.Background() + + issue := &types.Issue{ + ID: "bd-1", + Title: "Issue with Comments", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + // Add a comment to the comments table (not events) + if _, err := store.AddIssueComment(ctx, "bd-1", "test-author", "This is a test comment"); err != nil { + t.Fatalf("Failed to add comment: %v", err) + } + + // Verify comment exists + commentsMap, err := store.GetCommentsForIssues(ctx, []string{"bd-1"}) + if err != nil { + t.Fatalf("Failed to get comments: %v", err) + } + if len(commentsMap["bd-1"]) != 1 { + t.Fatalf("Expected 1 comment, got %d", len(commentsMap["bd-1"])) + } + + // Delete the issue + if err := store.DeleteIssue(ctx, "bd-1"); err != nil { + t.Fatalf("DeleteIssue failed: %v", err) + } + + // Verify issue deleted + if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil { + t.Error("Issue should be deleted") + } + + // Verify comments also deleted (should not leak) + commentsMap, err = store.GetCommentsForIssues(ctx, []string{"bd-1"}) + if err != nil { + t.Fatalf("Failed to get comments after delete: %v", err) + } + if len(commentsMap["bd-1"]) != 0 { + t.Errorf("Comments should be deleted, but found %d", len(commentsMap["bd-1"])) + } +} + func TestBuildIDSet(t *testing.T) { ids := []string{"bd-1", "bd-2", "bd-3"} idSet := buildIDSet(ids) diff --git a/internal/storage/sqlite/multirepo_export.go b/internal/storage/sqlite/multirepo_export.go index 40f1c6f4..2ffb5dfc 100644 --- a/internal/storage/sqlite/multirepo_export.go +++ b/internal/storage/sqlite/multirepo_export.go @@ -50,6 +50,16 @@ func (s *SQLiteStorage) ExportToMultiRepo(ctx context.Context) (map[string]int, issue.Labels = labels } + // Filter out ephemeral issues - they should never be exported to JSONL (bd-687g) + // Ephemeral issues exist only in SQLite and are shared via .beads/redirect, not JSONL. + filtered := make([]*types.Issue, 0, len(allIssues)) + for _, issue := range allIssues { + if !issue.Ephemeral { + filtered = append(filtered, issue) + } + } + allIssues = filtered + // Group issues by source_repo issuesByRepo := make(map[string][]*types.Issue) for _, issue := range allIssues { diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index b9c90d2d..f63a80fe 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -1093,6 +1093,12 @@ func (s *SQLiteStorage) DeleteIssue(ctx context.Context, id string) error { return fmt.Errorf("failed to delete events: %w", err) } + // Delete comments (no FK cascade on this table) (bd-687g) + _, err = tx.ExecContext(ctx, `DELETE FROM comments WHERE issue_id = ?`, id) + if err != nil { + return fmt.Errorf("failed to delete comments: %w", err) + } + // Delete from dirty_issues _, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id) if err != nil {