From 52135d0370858a996502f72454cd365925e4ac95 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 20:33:06 -0800 Subject: [PATCH] feat(types): add template molecules infrastructure for beads-1ra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for template molecules (is_template field and TypeMolecule type): - Add IsTemplate field to Issue type with JSON support - Add TypeMolecule constant to IssueType constants - Add IsTemplate filter to IssueFilter for querying - Update all SQL queries to include is_template column - Add migration 024 for is_template column - Add FindMoleculesJSONLInDir helper for molecules.jsonl path detection This enables treating certain issues as read-only templates that can be instantiated to create work items. The template flag allows separating template molecules from regular work items in queries and exports. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads.go | 11 ++--- internal/storage/sqlite/dependencies.go | 20 ++++++++-- internal/storage/sqlite/issues.go | 20 +++++++--- internal/storage/sqlite/labels.go | 2 +- internal/storage/sqlite/migrations.go | 2 + .../migrations/024_is_template_column.go | 40 +++++++++++++++++++ internal/storage/sqlite/migrations_test.go | 3 +- internal/storage/sqlite/multirepo.go | 14 ++++--- internal/storage/sqlite/queries.go | 31 +++++++++++--- internal/storage/sqlite/ready.go | 12 ++++-- internal/storage/sqlite/schema.go | 2 + internal/storage/sqlite/transaction.go | 12 ++++-- internal/types/types.go | 12 +++++- internal/utils/path.go | 14 +++++++ 14 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 internal/storage/sqlite/migrations/024_is_template_column.go diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 6cec6b64..b35efcad 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -211,11 +211,12 @@ const ( // IssueType constants const ( - TypeBug = types.TypeBug - TypeFeature = types.TypeFeature - TypeTask = types.TypeTask - TypeEpic = types.TypeEpic - TypeChore = types.TypeChore + TypeBug = types.TypeBug + TypeFeature = types.TypeFeature + TypeTask = types.TypeTask + TypeEpic = types.TypeEpic + TypeChore = types.TypeChore + TypeMolecule = types.TypeMolecule ) // DependencyType constants diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index 3d4cee48..a8ab3429 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -233,7 +233,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, - i.sender, i.ephemeral, i.pinned, + i.sender, i.ephemeral, i.pinned, i.is_template, d.type FROM issues i JOIN dependencies d ON i.id = d.depends_on_id @@ -255,7 +255,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, - i.sender, i.ephemeral, i.pinned, + i.sender, i.ephemeral, i.pinned, i.is_template, d.type FROM issues i JOIN dependencies d ON i.id = d.issue_id @@ -716,6 +716,8 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type var ephemeral sql.NullInt64 // Pinned field (bd-7h5) var pinned sql.NullInt64 + // Template field (beads-1ra) + var isTemplate sql.NullInt64 err := rows.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -723,7 +725,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, &pinned, + &sender, &ephemeral, &pinned, &isTemplate, ) if err != nil { return nil, fmt.Errorf("failed to scan issue: %w", err) @@ -772,6 +774,10 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type if pinned.Valid && pinned.Int64 != 0 { issue.Pinned = true } + // Template field (beads-1ra) + if isTemplate.Valid && isTemplate.Int64 != 0 { + issue.IsTemplate = true + } issues = append(issues, &issue) issueIDs = append(issueIDs, issue.ID) @@ -813,6 +819,8 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * var ephemeral sql.NullInt64 // Pinned field (bd-7h5) var pinned sql.NullInt64 + // Template field (beads-1ra) + var isTemplate sql.NullInt64 var depType types.DependencyType err := rows.Scan( @@ -821,7 +829,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, &pinned, + &sender, &ephemeral, &pinned, &isTemplate, &depType, ) if err != nil { @@ -868,6 +876,10 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * if pinned.Valid && pinned.Int64 != 0 { issue.Pinned = true } + // Template field (beads-1ra) + if isTemplate.Valid && isTemplate.Int64 != 0 { + issue.IsTemplate = true + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) diff --git a/internal/storage/sqlite/issues.go b/internal/storage/sqlite/issues.go index c3e4bcfb..fa499362 100644 --- a/internal/storage/sqlite/issues.go +++ b/internal/storage/sqlite/issues.go @@ -35,6 +35,10 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error if issue.Pinned { pinned = 1 } + isTemplate := 0 + if issue.IsTemplate { + isTemplate = 1 + } _, err := conn.ExecContext(ctx, ` INSERT OR IGNORE INTO issues ( @@ -42,8 +46,8 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sender, ephemeral, pinned, is_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -51,7 +55,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, pinned, + issue.Sender, ephemeral, pinned, isTemplate, ) if err != nil { // INSERT OR IGNORE should handle duplicates, but driver may still return error @@ -72,8 +76,8 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sender, ephemeral, pinned, is_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -94,6 +98,10 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er if issue.Pinned { pinned = 1 } + isTemplate := 0 + if issue.IsTemplate { + isTemplate = 1 + } _, err = stmt.ExecContext(ctx, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, @@ -102,7 +110,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, pinned, + issue.Sender, ephemeral, pinned, isTemplate, ) if err != nil { // INSERT OR IGNORE should handle duplicates, but driver may still return error diff --git a/internal/storage/sqlite/labels.go b/internal/storage/sqlite/labels.go index c07a20b1..5346dd93 100644 --- a/internal/storage/sqlite/labels.go +++ b/internal/storage/sqlite/labels.go @@ -159,7 +159,7 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]* i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason, i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, - i.sender, i.ephemeral, i.pinned + i.sender, i.ephemeral, i.pinned, i.is_template FROM issues i JOIN labels l ON i.id = l.issue_id WHERE l.label = ? diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index 5fdaa58d..f3136e1e 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -40,6 +40,7 @@ var migrationsList = []Migration{ {"migrate_edge_fields", migrations.MigrateEdgeFields}, {"drop_edge_columns", migrations.MigrateDropEdgeColumns}, {"pinned_column", migrations.MigratePinnedColumn}, + {"is_template_column", migrations.MigrateIsTemplateColumn}, } // MigrationInfo contains metadata about a migration for inspection @@ -87,6 +88,7 @@ func getMigrationDescription(name string) string { "migrate_edge_fields": "Migrates existing issue fields (replies_to, relates_to, duplicate_of, superseded_by) to dependency edges (Decision 004 Phase 3)", "drop_edge_columns": "Drops deprecated edge columns (replies_to, relates_to, duplicate_of, superseded_by) from issues table (Decision 004 Phase 4)", "pinned_column": "Adds pinned column for persistent context markers (bd-7h5)", + "is_template_column": "Adds is_template column for template molecules (beads-1ra)", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/024_is_template_column.go b/internal/storage/sqlite/migrations/024_is_template_column.go new file mode 100644 index 00000000..07f9462c --- /dev/null +++ b/internal/storage/sqlite/migrations/024_is_template_column.go @@ -0,0 +1,40 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateIsTemplateColumn adds the is_template column to the issues table. +// Template issues (molecules) are read-only templates that should be filtered +// from work views by default (beads-1ra). +func MigrateIsTemplateColumn(db *sql.DB) error { + // Check if column already exists + var columnExists bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('issues') + WHERE name = 'is_template' + `).Scan(&columnExists) + if err != nil { + return fmt.Errorf("failed to check is_template column: %w", err) + } + + if columnExists { + return nil + } + + // Add the is_template column + _, err = db.Exec(`ALTER TABLE issues ADD COLUMN is_template INTEGER DEFAULT 0`) + if err != nil { + return fmt.Errorf("failed to add is_template column: %w", err) + } + + // Add index for template issues (for efficient filtering) + _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_is_template ON issues(is_template) WHERE is_template = 1`) + if err != nil { + return fmt.Errorf("failed to create is_template index: %w", err) + } + + return nil +} diff --git a/internal/storage/sqlite/migrations_test.go b/internal/storage/sqlite/migrations_test.go index 88f6da59..565c4842 100644 --- a/internal/storage/sqlite/migrations_test.go +++ b/internal/storage/sqlite/migrations_test.go @@ -486,13 +486,14 @@ func TestMigrateContentHashColumn(t *testing.T) { sender TEXT DEFAULT '', ephemeral INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, + is_template INTEGER DEFAULT 0, replies_to TEXT DEFAULT '', relates_to TEXT DEFAULT '', duplicate_of TEXT DEFAULT '', superseded_by TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ); - INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, '', '', '', '' FROM issues_backup; + INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '' FROM issues_backup; DROP TABLE issues_backup; `) if err != nil { diff --git a/internal/storage/sqlite/multirepo.go b/internal/storage/sqlite/multirepo.go index 86861bdb..1d452ad1 100644 --- a/internal/storage/sqlite/multirepo.go +++ b/internal/storage/sqlite/multirepo.go @@ -265,6 +265,10 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * if issue.Pinned { pinned = 1 } + isTemplate := 0 + if issue.IsTemplate { + isTemplate = 1 + } if err == sql.ErrNoRows { // Issue doesn't exist - insert it @@ -274,8 +278,8 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sender, ephemeral, pinned, is_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -283,7 +287,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, pinned, + issue.Sender, ephemeral, pinned, isTemplate, ) if err != nil { return fmt.Errorf("failed to insert issue: %w", err) @@ -307,7 +311,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue_type = ?, assignee = ?, estimated_minutes = ?, updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?, deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?, - sender = ?, ephemeral = ?, pinned = ? + sender = ?, ephemeral = ?, pinned = ?, is_template = ? WHERE id = ? `, issue.ContentHash, issue.Title, issue.Description, issue.Design, @@ -315,7 +319,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, pinned, + issue.Sender, ephemeral, pinned, isTemplate, issue.ID, ) if err != nil { diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 21871080..13bca543 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -250,6 +250,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, var ephemeral sql.NullInt64 // Pinned field (bd-7h5) var pinned sql.NullInt64 + // Template field (beads-1ra) + var isTemplate sql.NullInt64 var contentHash sql.NullString var compactedAtCommit sql.NullString @@ -259,7 +261,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned + sender, ephemeral, pinned, is_template FROM issues WHERE id = ? `, id).Scan( @@ -269,7 +271,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, &pinned, + &sender, &ephemeral, &pinned, &isTemplate, ) if err == sql.ErrNoRows { @@ -331,6 +333,10 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if pinned.Valid && pinned.Int64 != 0 { issue.Pinned = true } + // Template field (beads-1ra) + if isTemplate.Valid && isTemplate.Int64 != 0 { + issue.IsTemplate = true + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -439,6 +445,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s var ephemeral sql.NullInt64 // Pinned field (bd-7h5) var pinned sql.NullInt64 + // Template field (beads-1ra) + var isTemplate sql.NullInt64 err := s.db.QueryRowContext(ctx, ` SELECT id, content_hash, title, description, design, acceptance_criteria, notes, @@ -446,7 +454,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned + sender, ephemeral, pinned, is_template FROM issues WHERE external_ref = ? `, externalRef).Scan( @@ -456,7 +464,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, &pinned, + &sender, &ephemeral, &pinned, &isTemplate, ) if err == sql.ErrNoRows { @@ -518,6 +526,10 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s if pinned.Valid && pinned.Int64 != 0 { issue.Pinned = true } + // Template field (beads-1ra) + if isTemplate.Valid && isTemplate.Int64 != 0 { + issue.IsTemplate = true + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -1589,6 +1601,15 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t } } + // Template filtering (beads-1ra) + if filter.IsTemplate != nil { + if *filter.IsTemplate { + whereClauses = append(whereClauses, "is_template = 1") + } else { + whereClauses = append(whereClauses, "(is_template = 0 OR is_template IS NULL)") + } + } + whereSQL := "" if len(whereClauses) > 0 { whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") @@ -1606,7 +1627,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned + sender, ephemeral, pinned, is_template FROM issues %s ORDER BY priority ASC, created_at DESC diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index d60b84c2..b0398c5c 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -110,7 +110,7 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason, i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, - i.sender, i.ephemeral, i.pinned + i.sender, i.ephemeral, i.pinned, i.is_template FROM issues i WHERE %s AND NOT EXISTS ( @@ -139,7 +139,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi created_at, updated_at, closed_at, external_ref, source_repo, compaction_level, compacted_at, compacted_at_commit, original_size, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned + sender, ephemeral, pinned, is_template FROM issues WHERE status != 'closed' AND datetime(updated_at) < datetime('now', '-' || ? || ' days') @@ -190,6 +190,8 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi var ephemeral sql.NullInt64 // Pinned field (bd-7h5) var pinned sql.NullInt64 + // Template field (beads-1ra) + var isTemplate sql.NullInt64 err := rows.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -198,7 +200,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, &pinned, + &sender, &ephemeral, &pinned, &isTemplate, ) if err != nil { return nil, fmt.Errorf("failed to scan stale issue: %w", err) @@ -259,6 +261,10 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi if pinned.Valid && pinned.Int64 != 0 { issue.Pinned = true } + // Template field (beads-1ra) + if isTemplate.Valid && isTemplate.Int64 != 0 { + issue.IsTemplate = true + } issues = append(issues, &issue) } diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index a433a6ac..7b533af6 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -32,6 +32,8 @@ CREATE TABLE IF NOT EXISTS issues ( ephemeral INTEGER DEFAULT 0, -- Pinned field (bd-7h5) pinned INTEGER DEFAULT 0, + -- Template field (beads-1ra) + is_template INTEGER DEFAULT 0, -- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004 -- These relationships are now stored in the dependencies table CHECK ((status = 'closed') = (closed_at IS NOT NULL)) diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index cb645046..8bb1ddc5 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -306,7 +306,7 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned + sender, ephemeral, pinned, is_template FROM issues WHERE id = ? `, id) @@ -1107,7 +1107,7 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral, pinned + sender, ephemeral, pinned, is_template FROM issues %s ORDER BY priority ASC, created_at DESC @@ -1152,6 +1152,8 @@ func scanIssueRow(row scanner) (*types.Issue, error) { var ephemeral sql.NullInt64 // Pinned field (bd-7h5) var pinned sql.NullInt64 + // Template field (beads-1ra) + var isTemplate sql.NullInt64 err := row.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -1160,7 +1162,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, &pinned, + &sender, &ephemeral, &pinned, &isTemplate, ) if err != nil { return nil, fmt.Errorf("failed to scan issue: %w", err) @@ -1218,6 +1220,10 @@ func scanIssueRow(row scanner) (*types.Issue, error) { if pinned.Valid && pinned.Int64 != 0 { issue.Pinned = true } + // Template field (beads-1ra) + if isTemplate.Valid && isTemplate.Int64 != 0 { + issue.IsTemplate = true + } return &issue, nil } diff --git a/internal/types/types.go b/internal/types/types.go index 5fcce043..7e32e2a4 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -48,6 +48,9 @@ type Issue struct { // Pinned field (bd-7h5): persistent context markers Pinned bool `json:"pinned,omitempty"` // If true, issue is a persistent context marker, not a work item + + // Template field (beads-1ra): template molecule support + IsTemplate bool `json:"is_template,omitempty"` // If true, issue is a read-only template molecule } // ComputeContentHash creates a deterministic hash of the issue's content. @@ -83,6 +86,10 @@ func (i *Issue) ComputeContentHash() string { if i.Pinned { h.Write([]byte("pinned")) } + h.Write([]byte{0}) + if i.IsTemplate { + h.Write([]byte("template")) + } return fmt.Sprintf("%x", h.Sum(nil)) } @@ -233,7 +240,7 @@ const ( TypeChore IssueType = "chore" TypeMessage IssueType = "message" // Ephemeral communication between workers TypeMergeRequest IssueType = "merge-request" // Merge queue entry for refinery processing - TypeMolecule IssueType = "molecule" // Composable workflow template + TypeMolecule IssueType = "molecule" // Template molecule for issue hierarchies (beads-1ra) ) // IsValid checks if the issue type value is valid @@ -446,6 +453,9 @@ type IssueFilter struct { // Pinned filtering (bd-7h5) Pinned *bool // Filter by pinned flag (nil = any, true = only pinned, false = only non-pinned) + + // Template filtering (beads-1ra) + IsTemplate *bool // Filter by template flag (nil = any, true = only templates, false = exclude templates) } // SortPolicy determines how ready work is ordered diff --git a/internal/utils/path.go b/internal/utils/path.go index 5bbf2709..d03c63e0 100644 --- a/internal/utils/path.go +++ b/internal/utils/path.go @@ -2,6 +2,7 @@ package utils import ( + "os" "path/filepath" ) @@ -55,6 +56,19 @@ func FindJSONLInDir(dbDir string) string { return filepath.Join(dbDir, "issues.jsonl") } +// FindMoleculesJSONLInDir finds the molecules.jsonl file in the given .beads directory. +// Returns the path to molecules.jsonl if it exists, empty string otherwise. +// Molecules are template issues used for instantiation (beads-1ra). +func FindMoleculesJSONLInDir(dbDir string) string { + moleculesPath := filepath.Join(dbDir, "molecules.jsonl") + // Check if file exists - we don't fall back to any other file + // because molecules.jsonl is optional and specific + if _, err := os.Stat(moleculesPath); err == nil { + return moleculesPath + } + return "" +} + // CanonicalizePath converts a path to its canonical form by: // 1. Converting to absolute path // 2. Resolving symlinks