// 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 }