feat(molecules): add bd molecule commands and template protection (beads-1ra)

- Add `bd molecule list` to list template molecules
- Add `bd molecule show` to show molecule details
- Add `bd molecule instantiate` to create work items from templates
- Exclude templates from `bd list` by default (use --include-templates)
- Reject mutations (update/close/delete) to template issues
- Add IncludeTemplates to RPC ListArgs for daemon mode

Templates are marked with is_template=true and are read-only.
Use `bd molecule instantiate` to create editable work items.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 22:14:54 -08:00
parent 3e715ad26c
commit ad4e813e71
5 changed files with 475 additions and 28 deletions

View File

@@ -159,6 +159,9 @@ type ListArgs struct {
// Pinned filtering (bd-p8e)
Pinned *bool `json:"pinned,omitempty"`
// Template filtering (beads-1ra)
IncludeTemplates bool `json:"include_templates,omitempty"`
}
// CountArgs represents arguments for the count operation

View File

@@ -337,6 +337,28 @@ func (s *Server) handleUpdate(req *Request) Response {
}
ctx := s.reqCtx(req)
// Check if issue is a template (beads-1ra): templates are read-only
issue, err := store.GetIssue(ctx, updateArgs.ID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get issue: %v", err),
}
}
if issue == nil {
return Response{
Success: false,
Error: fmt.Sprintf("issue %s not found", updateArgs.ID),
}
}
if issue.IsTemplate {
return Response{
Success: false,
Error: fmt.Sprintf("cannot update template %s: templates are read-only; use 'bd molecule instantiate' to create a work item", updateArgs.ID),
}
}
updates := updatesFromArgs(updateArgs)
actor := s.reqActor(req)
@@ -406,15 +428,15 @@ func (s *Server) handleUpdate(req *Request) Response {
s.emitMutation(MutationUpdate, updateArgs.ID)
}
issue, err := store.GetIssue(ctx, updateArgs.ID)
if err != nil {
updatedIssue, getErr := store.GetIssue(ctx, updateArgs.ID)
if getErr != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get updated issue: %v", err),
Error: fmt.Sprintf("failed to get updated issue: %v", getErr),
}
}
data, _ := json.Marshal(issue)
data, _ := json.Marshal(updatedIssue)
return Response{
Success: true,
Data: data,
@@ -439,6 +461,22 @@ func (s *Server) handleClose(req *Request) Response {
}
ctx := s.reqCtx(req)
// Check if issue is a template (beads-1ra): templates are read-only
issue, err := store.GetIssue(ctx, closeArgs.ID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get issue: %v", err),
}
}
if issue != nil && issue.IsTemplate {
return Response{
Success: false,
Error: fmt.Sprintf("cannot close template %s: templates are read-only", closeArgs.ID),
}
}
if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil {
return Response{
Success: false,
@@ -449,8 +487,8 @@ func (s *Server) handleClose(req *Request) Response {
// Emit mutation event for event-driven daemon
s.emitMutation(MutationUpdate, closeArgs.ID)
issue, _ := store.GetIssue(ctx, closeArgs.ID)
data, _ := json.Marshal(issue)
closedIssue, _ := store.GetIssue(ctx, closeArgs.ID)
data, _ := json.Marshal(closedIssue)
return Response{
Success: true,
Data: data,
@@ -512,6 +550,12 @@ func (s *Server) handleDelete(req *Request) Response {
continue
}
// Check if issue is a template (beads-1ra): templates are read-only
if issue.IsTemplate {
errors = append(errors, fmt.Sprintf("%s: cannot delete template (templates are read-only)", issueID))
continue
}
// Create tombstone instead of hard delete (bd-rp4o fix)
// This preserves deletion history and prevents resurrection during sync
type tombstoner interface {
@@ -701,6 +745,12 @@ func (s *Server) handleList(req *Request) Response {
// Pinned filtering (bd-p8e)
filter.Pinned = listArgs.Pinned
// Template filtering (beads-1ra): exclude templates by default
if !listArgs.IncludeTemplates {
isTemplate := false
filter.IsTemplate = &isTemplate
}
// Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000
if len(filter.IDs) > maxIDs {