From f6392efd07a92ec74bfaaa345187f67c193fafe9 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 20 Dec 2025 09:27:17 -0800 Subject: [PATCH] feat(molecules): Add molecules.jsonl as separate catalog file for templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements gt-0ei3: Template molecules now live in a separate molecules.jsonl file, distinct from work items in issues.jsonl. Key changes: - Add internal/molecules package for loading molecule catalogs - Implement hierarchical loading: built-in → town → user → project - Molecules use their own ID namespace (mol-*) with prefix validation skipped - Templates are marked with is_template: true and are read-only - bd list excludes templates by default (existing functionality) The hierarchical loading allows: - Built-in molecules shipped with bd binary (placeholder for future) - Town-level: ~/gt/.beads/molecules.jsonl (Gas Town) - User-level: ~/.beads/molecules.jsonl - Project-level: .beads/molecules.jsonl 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/main.go | 14 ++ internal/molecules/molecules.go | 266 +++++++++++++++++++++++++++ internal/molecules/molecules_test.go | 209 +++++++++++++++++++++ 3 files changed, 489 insertions(+) create mode 100644 internal/molecules/molecules.go create mode 100644 internal/molecules/molecules_test.go diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 678d101a..3f06fb0d 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -19,6 +19,7 @@ import ( "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/hooks" + "github.com/steveyegge/beads/internal/molecules" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/memory" @@ -633,6 +634,19 @@ var rootCmd = &cobra.Command{ autoImportIfNewer() } } + + // Load molecule templates from hierarchical catalog locations (gt-0ei3) + // Templates are loaded after auto-import to ensure the database is up-to-date. + // Skip for import command to avoid conflicts during import operations. + if cmd.Name() != "import" && store != nil { + beadsDir := filepath.Dir(dbPath) + loader := molecules.NewLoader(store) + if result, err := loader.LoadAll(rootCtx, beadsDir); err != nil { + debug.Logf("warning: failed to load molecules: %v", err) + } else if result.Loaded > 0 { + debug.Logf("loaded %d molecules from %v", result.Loaded, result.Sources) + } + } }, PersistentPostRun: func(cmd *cobra.Command, args []string) { // Handle --no-db mode: write memory storage back to JSONL diff --git a/internal/molecules/molecules.go b/internal/molecules/molecules.go new file mode 100644 index 00000000..d8d33cf4 --- /dev/null +++ b/internal/molecules/molecules.go @@ -0,0 +1,266 @@ +// Package molecules handles loading template molecules from molecules.jsonl catalogs. +// +// Template molecules are read-only issue templates that can be instantiated as +// work items. They live in a separate molecules.jsonl file, distinct from work +// items in issues.jsonl. +// +// # Hierarchical Loading +// +// Molecules are loaded from multiple locations in priority order (later overrides earlier): +// 1. Built-in molecules (shipped with bd binary) +// 2. Town-level: ~/gt/.beads/molecules.jsonl (if Gas Town is detected) +// 3. User-level: ~/.beads/molecules.jsonl +// 4. Project-level: .beads/molecules.jsonl in the current project +// +// # Key Properties +// +// - Templates are marked with is_template: true +// - Templates are read-only (mutations are rejected) +// - bd list excludes templates by default +// - bd molecule list shows the catalog +package molecules + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/beads/internal/debug" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// MoleculeFileName is the canonical name for molecule catalog files. +const MoleculeFileName = "molecules.jsonl" + +// LoadResult contains statistics about the molecule loading operation. +type LoadResult struct { + Loaded int // Number of molecules successfully loaded + Skipped int // Number of molecules skipped (already exist or errors) + Sources []string // Paths that were loaded from + BuiltinCount int // Number of built-in molecules loaded +} + +// Loader handles loading molecule catalogs from hierarchical locations. +type Loader struct { + store storage.Storage +} + +// NewLoader creates a new molecule loader for the given storage. +func NewLoader(store storage.Storage) *Loader { + return &Loader{store: store} +} + +// LoadAll loads molecules from all available catalog locations. +// Molecules are loaded in priority order: built-in < town < user < project. +// Later sources override earlier ones if they have the same ID. +func (l *Loader) LoadAll(ctx context.Context, beadsDir string) (*LoadResult, error) { + result := &LoadResult{ + Sources: make([]string, 0), + } + + // 1. Load built-in molecules (embedded in binary) + builtinMolecules := getBuiltinMolecules() + if len(builtinMolecules) > 0 { + count, err := l.loadMolecules(ctx, builtinMolecules) + if err != nil { + debug.Logf("warning: failed to load built-in molecules: %v", err) + } else { + result.BuiltinCount = count + result.Loaded += count + result.Sources = append(result.Sources, "") + } + } + + // 2. Load town-level molecules (Gas Town: ~/gt/.beads/molecules.jsonl) + townPath := getTownMoleculesPath() + if townPath != "" { + if molecules, err := loadMoleculesFromFile(townPath); err == nil && len(molecules) > 0 { + count, err := l.loadMolecules(ctx, molecules) + if err != nil { + debug.Logf("warning: failed to load town molecules: %v", err) + } else { + result.Loaded += count + result.Sources = append(result.Sources, townPath) + } + } + } + + // 3. Load user-level molecules (~/.beads/molecules.jsonl) + userPath := getUserMoleculesPath() + if userPath != "" && userPath != townPath { + if molecules, err := loadMoleculesFromFile(userPath); err == nil && len(molecules) > 0 { + count, err := l.loadMolecules(ctx, molecules) + if err != nil { + debug.Logf("warning: failed to load user molecules: %v", err) + } else { + result.Loaded += count + result.Sources = append(result.Sources, userPath) + } + } + } + + // 4. Load project-level molecules (.beads/molecules.jsonl) + if beadsDir != "" { + projectPath := filepath.Join(beadsDir, MoleculeFileName) + if molecules, err := loadMoleculesFromFile(projectPath); err == nil && len(molecules) > 0 { + count, err := l.loadMolecules(ctx, molecules) + if err != nil { + debug.Logf("warning: failed to load project molecules: %v", err) + } else { + result.Loaded += count + result.Sources = append(result.Sources, projectPath) + } + } + } + + return result, nil +} + +// loadMolecules loads a slice of molecules into the store. +// Each molecule is marked as a template (IsTemplate = true). +// Returns the number of molecules successfully loaded. +func (l *Loader) loadMolecules(ctx context.Context, molecules []*types.Issue) (int, error) { + // Filter out molecules that already exist + var newMolecules []*types.Issue + for _, mol := range molecules { + // Ensure molecule is marked as a template + mol.IsTemplate = true + + // Check if molecule already exists + existing, err := l.store.GetIssue(ctx, mol.ID) + if err == nil && existing != nil { + // Already exists - skip (or could update if newer) + debug.Logf("molecule %s already exists, skipping", mol.ID) + continue + } + + newMolecules = append(newMolecules, mol) + } + + if len(newMolecules) == 0 { + return 0, nil + } + + // Use batch creation with prefix validation skipped. + // Molecules have their own ID namespace (mol-*) independent of project prefix. + if sqliteStore, ok := l.store.(*sqlite.SQLiteStorage); ok { + opts := sqlite.BatchCreateOptions{ + SkipPrefixValidation: true, // Molecules use their own prefix + } + if err := sqliteStore.CreateIssuesWithFullOptions(ctx, newMolecules, "molecules-loader", opts); err != nil { + return 0, fmt.Errorf("batch create molecules: %w", err) + } + return len(newMolecules), nil + } + + // Fallback for non-SQLite stores (e.g., memory storage in tests) + loaded := 0 + for _, mol := range newMolecules { + if err := l.store.CreateIssue(ctx, mol, "molecules-loader"); err != nil { + debug.Logf("failed to load molecule %s: %v", mol.ID, err) + continue + } + loaded++ + } + + return loaded, nil +} + +// loadMoleculesFromFile loads molecules from a JSONL file. +func loadMoleculesFromFile(path string) ([]*types.Issue, error) { + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, nil + } + + // #nosec G304 - path is constructed from known safe locations + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open %s: %w", path, err) + } + defer file.Close() + + var molecules []*types.Issue + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := scanner.Text() + + // Skip empty lines + if strings.TrimSpace(line) == "" { + continue + } + + var issue types.Issue + if err := json.Unmarshal([]byte(line), &issue); err != nil { + debug.Logf("warning: %s line %d: %v", path, lineNum, err) + continue + } + + // Mark as template + issue.IsTemplate = true + issue.SetDefaults() + + molecules = append(molecules, &issue) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading %s: %w", path, err) + } + + return molecules, nil +} + +// getTownMoleculesPath returns the path to town-level molecules.jsonl +// if Gas Town is detected (~/gt/.beads/molecules.jsonl). +func getTownMoleculesPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + // Check for Gas Town installation + gtPath := filepath.Join(homeDir, "gt", ".beads", MoleculeFileName) + if _, err := os.Stat(gtPath); err == nil { + return gtPath + } + + return "" +} + +// getUserMoleculesPath returns the path to user-level molecules.jsonl +// (~/.beads/molecules.jsonl). +func getUserMoleculesPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + userPath := filepath.Join(homeDir, ".beads", MoleculeFileName) + if _, err := os.Stat(userPath); err == nil { + return userPath + } + + return "" +} + +// getBuiltinMolecules returns the built-in molecule templates shipped with bd. +// These provide common workflow patterns out of the box. +func getBuiltinMolecules() []*types.Issue { + // For now, return an empty slice. Built-in molecules can be added later + // using Go embed or by defining them inline here. + // + // Example built-in molecules: + // - mol-feature: Standard feature workflow (design, implement, test, docs) + // - mol-bugfix: Bug fix workflow (reproduce, fix, verify, regression test) + // - mol-refactor: Refactoring workflow (identify, plan, implement, verify) + return nil +} diff --git a/internal/molecules/molecules_test.go b/internal/molecules/molecules_test.go new file mode 100644 index 00000000..bf351ce6 --- /dev/null +++ b/internal/molecules/molecules_test.go @@ -0,0 +1,209 @@ +package molecules + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +func TestLoadMoleculesFromFile(t *testing.T) { + // Create a temporary directory + tempDir := t.TempDir() + + // Create a test molecules.jsonl file + moleculesPath := filepath.Join(tempDir, "molecules.jsonl") + content := `{"id":"mol-test-1","title":"Test Molecule 1","issue_type":"molecule","status":"open"} +{"id":"mol-test-2","title":"Test Molecule 2","issue_type":"molecule","status":"open"}` + if err := os.WriteFile(moleculesPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Load molecules + molecules, err := loadMoleculesFromFile(moleculesPath) + if err != nil { + t.Fatalf("Failed to load molecules: %v", err) + } + + if len(molecules) != 2 { + t.Errorf("Expected 2 molecules, got %d", len(molecules)) + } + + // Check that IsTemplate is set + for _, mol := range molecules { + if !mol.IsTemplate { + t.Errorf("Molecule %s should have IsTemplate=true", mol.ID) + } + } + + // Check specific fields + if molecules[0].ID != "mol-test-1" { + t.Errorf("Expected ID 'mol-test-1', got '%s'", molecules[0].ID) + } + if molecules[0].Title != "Test Molecule 1" { + t.Errorf("Expected Title 'Test Molecule 1', got '%s'", molecules[0].Title) + } +} + +func TestLoadMoleculesFromNonexistentFile(t *testing.T) { + molecules, err := loadMoleculesFromFile("/nonexistent/path/molecules.jsonl") + if err != nil { + t.Errorf("Expected nil error for nonexistent file, got: %v", err) + } + if molecules != nil { + t.Errorf("Expected nil molecules for nonexistent file, got: %v", molecules) + } +} + +func TestLoader_LoadAll(t *testing.T) { + ctx := context.Background() + + // Create temporary directories + tempDir := t.TempDir() + beadsDir := filepath.Join(tempDir, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + t.Fatalf("Failed to create beads dir: %v", err) + } + + // Create a test database + dbPath := filepath.Join(beadsDir, "test.db") + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + // Set issue prefix (required by storage) + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set prefix: %v", err) + } + + // Create a project-level molecules.jsonl + moleculesPath := filepath.Join(beadsDir, "molecules.jsonl") + content := `{"id":"mol-feature","title":"Feature Template","issue_type":"molecule","status":"open","description":"Standard feature workflow"} +{"id":"mol-bugfix","title":"Bugfix Template","issue_type":"molecule","status":"open","description":"Bug fix workflow"}` + if err := os.WriteFile(moleculesPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write molecules file: %v", err) + } + + // Load molecules + loader := NewLoader(store) + result, err := loader.LoadAll(ctx, beadsDir) + if err != nil { + t.Fatalf("LoadAll failed: %v", err) + } + + if result.Loaded != 2 { + t.Errorf("Expected 2 loaded molecules, got %d", result.Loaded) + } + + // Verify molecules are in the database + mol1, err := store.GetIssue(ctx, "mol-feature") + if err != nil { + t.Fatalf("Failed to get mol-feature: %v", err) + } + if mol1 == nil { + t.Fatal("mol-feature not found in database") + } + if !mol1.IsTemplate { + t.Error("mol-feature should be marked as template") + } + if mol1.Title != "Feature Template" { + t.Errorf("Expected title 'Feature Template', got '%s'", mol1.Title) + } + + mol2, err := store.GetIssue(ctx, "mol-bugfix") + if err != nil { + t.Fatalf("Failed to get mol-bugfix: %v", err) + } + if mol2 == nil { + t.Fatal("mol-bugfix not found in database") + } + if !mol2.IsTemplate { + t.Error("mol-bugfix should be marked as template") + } +} + +func TestLoader_SkipExistingMolecules(t *testing.T) { + ctx := context.Background() + + // Create temporary directories + tempDir := t.TempDir() + beadsDir := filepath.Join(tempDir, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + t.Fatalf("Failed to create beads dir: %v", err) + } + + // Create a test database + dbPath := filepath.Join(beadsDir, "test.db") + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + // Set issue prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set prefix: %v", err) + } + + // Pre-create a molecule in the database (skip prefix validation for mol-* IDs) + existingMol := &types.Issue{ + ID: "mol-existing", + Title: "Existing Molecule", + IssueType: types.TypeMolecule, + Status: types.StatusOpen, + IsTemplate: true, + } + opts := sqlite.BatchCreateOptions{SkipPrefixValidation: true} + if err := store.CreateIssuesWithFullOptions(ctx, []*types.Issue{existingMol}, "test", opts); err != nil { + t.Fatalf("Failed to create existing molecule: %v", err) + } + + // Create a molecules.jsonl with the same ID + moleculesPath := filepath.Join(beadsDir, "molecules.jsonl") + content := `{"id":"mol-existing","title":"Updated Molecule","issue_type":"molecule","status":"open"} +{"id":"mol-new","title":"New Molecule","issue_type":"molecule","status":"open"}` + if err := os.WriteFile(moleculesPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write molecules file: %v", err) + } + + // Load molecules + loader := NewLoader(store) + result, err := loader.LoadAll(ctx, beadsDir) + if err != nil { + t.Fatalf("LoadAll failed: %v", err) + } + + // Should only load the new one (existing one is skipped) + if result.Loaded != 1 { + t.Errorf("Expected 1 loaded molecule, got %d", result.Loaded) + } + + // Verify the existing molecule wasn't updated + mol, err := store.GetIssue(ctx, "mol-existing") + if err != nil { + t.Fatalf("Failed to get mol-existing: %v", err) + } + if mol.Title != "Existing Molecule" { + t.Errorf("Expected title 'Existing Molecule' (unchanged), got '%s'", mol.Title) + } +} + +func TestGetBuiltinMolecules(t *testing.T) { + molecules := getBuiltinMolecules() + // For now, we expect no built-in molecules (can be added later) + if molecules == nil { + // This is expected for now + return + } + // When built-in molecules are added, verify they all have IsTemplate=true + for _, mol := range molecules { + if !mol.IsTemplate { + t.Errorf("Built-in molecule %s should have IsTemplate=true", mol.ID) + } + } +}