feat(molecule): Add molecule catalog with hierarchical loading
Implement MoleculeCatalog for loading molecules from multiple sources: 1. Built-in molecules (shipped with the gt binary) 2. Town-level: <town>/.beads/molecules.jsonl 3. Rig-level: <rig>/.beads/molecules.jsonl 4. Project-level: .beads/molecules.jsonl Changes: - Add internal/beads/catalog.go with MoleculeCatalog type - Update gt molecule list to show source (builtin, town, rig, project, database) - Update gt molecule show to check catalog first, then database - Update gt molecule instantiate to check catalog first - Add gt molecule export command to export built-in molecules to JSONL - Add --catalog and --db flags to gt molecule list The catalog enables organizations to share molecule templates independently of work item tracking, and allows customization at different levels of the workspace hierarchy. Closes gt-0ei3. Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
212
internal/beads/catalog.go
Normal file
212
internal/beads/catalog.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Package beads provides molecule catalog support for hierarchical template loading.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CatalogMolecule represents a molecule template in the catalog.
|
||||
// Unlike regular issues, catalog molecules are read-only templates.
|
||||
type CatalogMolecule struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source,omitempty"` // "builtin", "town", "rig", "project"
|
||||
}
|
||||
|
||||
// MoleculeCatalog provides hierarchical molecule template loading.
|
||||
// It loads molecules from multiple sources in priority order:
|
||||
// 1. Built-in molecules (shipped with the binary)
|
||||
// 2. Town-level: <town>/.beads/molecules.jsonl
|
||||
// 3. Rig-level: <town>/<rig>/.beads/molecules.jsonl
|
||||
// 4. Project-level: .beads/molecules.jsonl in current directory
|
||||
//
|
||||
// Later sources can override earlier ones by ID.
|
||||
type MoleculeCatalog struct {
|
||||
molecules map[string]*CatalogMolecule // ID -> molecule
|
||||
order []string // Insertion order for listing
|
||||
}
|
||||
|
||||
// NewMoleculeCatalog creates an empty catalog.
|
||||
func NewMoleculeCatalog() *MoleculeCatalog {
|
||||
return &MoleculeCatalog{
|
||||
molecules: make(map[string]*CatalogMolecule),
|
||||
order: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadCatalog creates a catalog with all molecule sources loaded.
|
||||
// Parameters:
|
||||
// - townRoot: Path to the Gas Town root (e.g., ~/gt). Empty to skip town-level.
|
||||
// - rigPath: Path to the rig directory (e.g., ~/gt/gastown). Empty to skip rig-level.
|
||||
// - projectPath: Path to the project directory. Empty to skip project-level.
|
||||
//
|
||||
// Built-in molecules are always loaded first.
|
||||
func LoadCatalog(townRoot, rigPath, projectPath string) (*MoleculeCatalog, error) {
|
||||
catalog := NewMoleculeCatalog()
|
||||
|
||||
// 1. Load built-in molecules
|
||||
for _, builtin := range BuiltinMolecules() {
|
||||
catalog.Add(&CatalogMolecule{
|
||||
ID: builtin.ID,
|
||||
Title: builtin.Title,
|
||||
Description: builtin.Description,
|
||||
Source: "builtin",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Load town-level molecules
|
||||
if townRoot != "" {
|
||||
townMolsPath := filepath.Join(townRoot, ".beads", "molecules.jsonl")
|
||||
if err := catalog.LoadFromFile(townMolsPath, "town"); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("loading town molecules: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load rig-level molecules
|
||||
if rigPath != "" {
|
||||
rigMolsPath := filepath.Join(rigPath, ".beads", "molecules.jsonl")
|
||||
if err := catalog.LoadFromFile(rigMolsPath, "rig"); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("loading rig molecules: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Load project-level molecules
|
||||
if projectPath != "" {
|
||||
projectMolsPath := filepath.Join(projectPath, ".beads", "molecules.jsonl")
|
||||
if err := catalog.LoadFromFile(projectMolsPath, "project"); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("loading project molecules: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
// Add adds or replaces a molecule in the catalog.
|
||||
func (c *MoleculeCatalog) Add(mol *CatalogMolecule) {
|
||||
if _, exists := c.molecules[mol.ID]; !exists {
|
||||
c.order = append(c.order, mol.ID)
|
||||
}
|
||||
c.molecules[mol.ID] = mol
|
||||
}
|
||||
|
||||
// Get returns a molecule by ID, or nil if not found.
|
||||
func (c *MoleculeCatalog) Get(id string) *CatalogMolecule {
|
||||
return c.molecules[id]
|
||||
}
|
||||
|
||||
// List returns all molecules in insertion order.
|
||||
func (c *MoleculeCatalog) List() []*CatalogMolecule {
|
||||
result := make([]*CatalogMolecule, 0, len(c.order))
|
||||
for _, id := range c.order {
|
||||
if mol, ok := c.molecules[id]; ok {
|
||||
result = append(result, mol)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Count returns the number of molecules in the catalog.
|
||||
func (c *MoleculeCatalog) Count() int {
|
||||
return len(c.molecules)
|
||||
}
|
||||
|
||||
// LoadFromFile loads molecules from a JSONL file.
|
||||
// Each line should be a JSON object with id, title, and description fields.
|
||||
// The source parameter is added to each loaded molecule.
|
||||
func (c *MoleculeCatalog) LoadFromFile(path, source string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
var mol CatalogMolecule
|
||||
if err := json.Unmarshal([]byte(line), &mol); err != nil {
|
||||
return fmt.Errorf("line %d: %w", lineNum, err)
|
||||
}
|
||||
|
||||
if mol.ID == "" {
|
||||
return fmt.Errorf("line %d: molecule missing id", lineNum)
|
||||
}
|
||||
|
||||
mol.Source = source
|
||||
c.Add(&mol)
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// SaveToFile writes all molecules to a JSONL file.
|
||||
// This is useful for exporting the catalog or creating template files.
|
||||
func (c *MoleculeCatalog) SaveToFile(path string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, mol := range c.List() {
|
||||
// Don't include source in exported file
|
||||
exportMol := struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}{
|
||||
ID: mol.ID,
|
||||
Title: mol.Title,
|
||||
Description: mol.Description,
|
||||
}
|
||||
if err := encoder.Encode(exportMol); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToIssue converts a catalog molecule to an Issue struct for compatibility.
|
||||
// The issue has Type="molecule" and is marked as a template.
|
||||
func (mol *CatalogMolecule) ToIssue() *Issue {
|
||||
return &Issue{
|
||||
ID: mol.ID,
|
||||
Title: mol.Title,
|
||||
Description: mol.Description,
|
||||
Type: "molecule",
|
||||
Status: "open",
|
||||
Priority: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportBuiltinMolecules writes all built-in molecules to a JSONL file.
|
||||
// This is useful for creating a base molecules.jsonl file.
|
||||
func ExportBuiltinMolecules(path string) error {
|
||||
catalog := NewMoleculeCatalog()
|
||||
for _, builtin := range BuiltinMolecules() {
|
||||
catalog.Add(&CatalogMolecule{
|
||||
ID: builtin.ID,
|
||||
Title: builtin.Title,
|
||||
Description: builtin.Description,
|
||||
Source: "builtin",
|
||||
})
|
||||
}
|
||||
return catalog.SaveToFile(path)
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// Molecule command flags
|
||||
@@ -16,6 +18,8 @@ var (
|
||||
moleculeJSON bool
|
||||
moleculeInstParent string
|
||||
moleculeInstContext []string
|
||||
moleculeCatalogOnly bool // List only catalog templates
|
||||
moleculeDBOnly bool // List only database molecules
|
||||
)
|
||||
|
||||
var moleculeCmd = &cobra.Command{
|
||||
@@ -32,10 +36,34 @@ var moleculeListCmd = &cobra.Command{
|
||||
Short: "List molecules",
|
||||
Long: `List all molecule definitions.
|
||||
|
||||
Molecules are issues with type=molecule.`,
|
||||
By default, lists molecules from all sources:
|
||||
- Built-in molecules (shipped with gt)
|
||||
- Town-level: <town>/.beads/molecules.jsonl
|
||||
- Rig-level: <rig>/.beads/molecules.jsonl
|
||||
- Project-level: .beads/molecules.jsonl
|
||||
- Database: molecules stored as issues
|
||||
|
||||
Use --catalog to show only template molecules (not instantiated).
|
||||
Use --db to show only database molecules.`,
|
||||
RunE: runMoleculeList,
|
||||
}
|
||||
|
||||
var moleculeExportCmd = &cobra.Command{
|
||||
Use: "export <path>",
|
||||
Short: "Export built-in molecules to JSONL",
|
||||
Long: `Export built-in molecule templates to a JSONL file.
|
||||
|
||||
This creates a molecules.jsonl file containing all built-in molecules.
|
||||
You can place this in:
|
||||
- <town>/.beads/molecules.jsonl (town-level)
|
||||
- <rig>/.beads/molecules.jsonl (rig-level)
|
||||
- .beads/molecules.jsonl (project-level)
|
||||
|
||||
The file can be edited to customize or add new molecules.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMoleculeExport,
|
||||
}
|
||||
|
||||
var moleculeShowCmd = &cobra.Command{
|
||||
Use: "show <id>",
|
||||
Short: "Show molecule with parsed steps",
|
||||
@@ -88,6 +116,8 @@ Lists each instantiation with its status and progress.`,
|
||||
func init() {
|
||||
// List flags
|
||||
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
||||
moleculeListCmd.Flags().BoolVar(&moleculeCatalogOnly, "catalog", false, "Show only catalog templates")
|
||||
moleculeListCmd.Flags().BoolVar(&moleculeDBOnly, "db", false, "Show only database molecules")
|
||||
|
||||
// Show flags
|
||||
moleculeShowCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
||||
@@ -109,6 +139,7 @@ func init() {
|
||||
moleculeCmd.AddCommand(moleculeParseCmd)
|
||||
moleculeCmd.AddCommand(moleculeInstantiateCmd)
|
||||
moleculeCmd.AddCommand(moleculeInstancesCmd)
|
||||
moleculeCmd.AddCommand(moleculeExportCmd)
|
||||
|
||||
rootCmd.AddCommand(moleculeCmd)
|
||||
}
|
||||
@@ -119,49 +150,141 @@ func runMoleculeList(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not in a beads workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(workDir)
|
||||
issues, err := b.List(beads.ListOptions{
|
||||
Type: "molecule",
|
||||
Status: "all",
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing molecules: %w", err)
|
||||
// Collect molecules from requested sources
|
||||
type moleculeEntry struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Source string `json:"source"`
|
||||
StepCount int `json:"step_count,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
var entries []moleculeEntry
|
||||
|
||||
// Load from catalog (unless --db only)
|
||||
if !moleculeDBOnly {
|
||||
catalog, err := loadMoleculeCatalog(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading catalog: %w", err)
|
||||
}
|
||||
|
||||
for _, mol := range catalog.List() {
|
||||
steps, _ := beads.ParseMoleculeSteps(mol.Description)
|
||||
entries = append(entries, moleculeEntry{
|
||||
ID: mol.ID,
|
||||
Title: mol.Title,
|
||||
Source: mol.Source,
|
||||
StepCount: len(steps),
|
||||
Description: mol.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load from database (unless --catalog only)
|
||||
if !moleculeCatalogOnly {
|
||||
b := beads.New(workDir)
|
||||
issues, err := b.List(beads.ListOptions{
|
||||
Type: "molecule",
|
||||
Status: "all",
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing molecules: %w", err)
|
||||
}
|
||||
|
||||
// Track catalog IDs to avoid duplicates
|
||||
catalogIDs := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
catalogIDs[e.ID] = true
|
||||
}
|
||||
|
||||
for _, mol := range issues {
|
||||
// Skip if already in catalog (catalog takes precedence)
|
||||
if catalogIDs[mol.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
steps, _ := beads.ParseMoleculeSteps(mol.Description)
|
||||
entries = append(entries, moleculeEntry{
|
||||
ID: mol.ID,
|
||||
Title: mol.Title,
|
||||
Source: "database",
|
||||
StepCount: len(steps),
|
||||
Status: mol.Status,
|
||||
Description: mol.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if moleculeJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(issues)
|
||||
return enc.Encode(entries)
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(issues))
|
||||
fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(entries))
|
||||
|
||||
if len(issues) == 0 {
|
||||
if len(entries) == 0 {
|
||||
fmt.Printf(" %s\n", style.Dim.Render("(no molecules defined)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, mol := range issues {
|
||||
for _, mol := range entries {
|
||||
sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source))
|
||||
|
||||
stepCount := ""
|
||||
if mol.StepCount > 0 {
|
||||
stepCount = fmt.Sprintf(" (%d steps)", mol.StepCount)
|
||||
}
|
||||
|
||||
statusMarker := ""
|
||||
if mol.Status == "closed" {
|
||||
statusMarker = " " + style.Dim.Render("[closed]")
|
||||
}
|
||||
|
||||
// Parse steps to show count
|
||||
steps, _ := beads.ParseMoleculeSteps(mol.Description)
|
||||
stepCount := ""
|
||||
if len(steps) > 0 {
|
||||
stepCount = fmt.Sprintf(" (%d steps)", len(steps))
|
||||
}
|
||||
|
||||
fmt.Printf(" %s: %s%s%s\n", style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker)
|
||||
fmt.Printf(" %s: %s%s%s %s\n",
|
||||
style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker, sourceMarker)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadMoleculeCatalog loads the molecule catalog with hierarchical sources.
|
||||
func loadMoleculeCatalog(workDir string) (*beads.MoleculeCatalog, error) {
|
||||
var townRoot, rigPath, projectPath string
|
||||
|
||||
// Try to find town root
|
||||
townRoot, _ = workspace.FindFromCwd()
|
||||
|
||||
// Try to find rig path
|
||||
if townRoot != "" {
|
||||
rigName, _, err := findCurrentRig(townRoot)
|
||||
if err == nil && rigName != "" {
|
||||
rigPath = filepath.Join(townRoot, rigName)
|
||||
}
|
||||
}
|
||||
|
||||
// Project path is the work directory
|
||||
projectPath = workDir
|
||||
|
||||
return beads.LoadCatalog(townRoot, rigPath, projectPath)
|
||||
}
|
||||
|
||||
func runMoleculeExport(cmd *cobra.Command, args []string) error {
|
||||
path := args[0]
|
||||
|
||||
if err := beads.ExportBuiltinMolecules(path); err != nil {
|
||||
return fmt.Errorf("exporting molecules: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Exported %d built-in molecules to %s\n",
|
||||
style.Bold.Render("✓"), len(beads.BuiltinMolecules()), path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMoleculeShow(cmd *cobra.Command, args []string) error {
|
||||
molID := args[0]
|
||||
|
||||
@@ -170,10 +293,26 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not in a beads workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(workDir)
|
||||
mol, err := b.Show(molID)
|
||||
// Try catalog first
|
||||
catalog, err := loadMoleculeCatalog(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting molecule: %w", err)
|
||||
return fmt.Errorf("loading catalog: %w", err)
|
||||
}
|
||||
|
||||
var mol *beads.Issue
|
||||
var source string
|
||||
|
||||
if catalogMol := catalog.Get(molID); catalogMol != nil {
|
||||
mol = catalogMol.ToIssue()
|
||||
source = catalogMol.Source
|
||||
} else {
|
||||
// Fall back to database
|
||||
b := beads.New(workDir)
|
||||
mol, err = b.Show(molID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting molecule: %w", err)
|
||||
}
|
||||
source = "database"
|
||||
}
|
||||
|
||||
if mol.Type != "molecule" {
|
||||
@@ -182,15 +321,17 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Parse steps
|
||||
steps, parseErr := beads.ParseMoleculeSteps(mol.Description)
|
||||
_ = source // Used below in output
|
||||
|
||||
// For JSON, include parsed steps
|
||||
if moleculeJSON {
|
||||
type moleculeOutput struct {
|
||||
*beads.Issue
|
||||
Source string `json:"source"`
|
||||
Steps []beads.MoleculeStep `json:"steps,omitempty"`
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
}
|
||||
out := moleculeOutput{Issue: mol, Steps: steps}
|
||||
out := moleculeOutput{Issue: mol, Source: source, Steps: steps}
|
||||
if parseErr != nil {
|
||||
out.ParseError = parseErr.Error()
|
||||
}
|
||||
@@ -200,7 +341,7 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("\n%s: %s\n", style.Bold.Render(mol.ID), mol.Title)
|
||||
fmt.Printf("\n%s: %s %s\n", style.Bold.Render(mol.ID), mol.Title, style.Dim.Render(fmt.Sprintf("[%s]", source)))
|
||||
fmt.Printf("Type: %s\n", mol.Type)
|
||||
|
||||
if parseErr != nil {
|
||||
@@ -230,7 +371,8 @@ func runMoleculeShow(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Count instances
|
||||
// Count instances (need beads client for this)
|
||||
b := beads.New(workDir)
|
||||
instances, _ := findMoleculeInstances(b, molID)
|
||||
fmt.Printf("\nInstances: %d\n", len(instances))
|
||||
|
||||
@@ -327,10 +469,22 @@ func runMoleculeInstantiate(cmd *cobra.Command, args []string) error {
|
||||
|
||||
b := beads.New(workDir)
|
||||
|
||||
// Get the molecule
|
||||
mol, err := b.Show(molID)
|
||||
// Try catalog first
|
||||
catalog, err := loadMoleculeCatalog(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting molecule: %w", err)
|
||||
return fmt.Errorf("loading catalog: %w", err)
|
||||
}
|
||||
|
||||
var mol *beads.Issue
|
||||
|
||||
if catalogMol := catalog.Get(molID); catalogMol != nil {
|
||||
mol = catalogMol.ToIssue()
|
||||
} else {
|
||||
// Fall back to database
|
||||
mol, err = b.Show(molID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting molecule: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if mol.Type != "molecule" {
|
||||
|
||||
Reference in New Issue
Block a user