feat(molecules): Add molecules.jsonl as separate catalog file for templates
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 <noreply@anthropic.com>
This commit is contained in:
266
internal/molecules/molecules.go
Normal file
266
internal/molecules/molecules.go
Normal file
@@ -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, "<built-in>")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
209
internal/molecules/molecules_test.go
Normal file
209
internal/molecules/molecules_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user