feat: add parallel step detection for molecules (bd-xo1o.4)

Add --parallel flag to `bd mol show` that analyzes molecule structure
and identifies which steps can run in parallel. Also add --mol flag to
`bd ready` to list ready steps within a specific molecule.

Features:
- `bd mol show <id> --parallel`: Shows parallel group annotations
- `bd ready --mol <id>`: Lists ready steps with parallel opportunities
- Detects steps at same blocking depth that can parallelize
- Groups steps without mutual blocking deps into parallel groups
- JSON output includes full parallel analysis

Algorithm:
- Build dependency graph from blocking deps (blocks, conditional-blocks)
- Calculate blocking depth (distance from unblocked state)
- Group steps at same depth with no mutual blocking into parallel groups
- Mark steps as ready if open/in_progress with no open blockers

🤖 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-23 03:56:53 -08:00
parent 72c1c431ef
commit 37fd1ce614
3 changed files with 812 additions and 2 deletions

View File

@@ -3,16 +3,32 @@ package main
import (
"fmt"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
var (
molShowParallel bool // --parallel flag for parallel detection
)
var molShowCmd = &cobra.Command{
Use: "show <molecule-id>",
Short: "Show molecule details",
Args: cobra.ExactArgs(1),
Long: `Show molecule structure and details.
The --parallel flag highlights parallelizable steps:
- Steps with no blocking dependencies can run in parallel
- Shows which steps are ready to start now
- Identifies parallel groups (steps that can run concurrently)
Example:
bd mol show bd-patrol --parallel`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
@@ -39,7 +55,11 @@ var molShowCmd = &cobra.Command{
os.Exit(1)
}
showMolecule(subgraph)
if molShowParallel {
showMoleculeWithParallel(subgraph)
} else {
showMolecule(subgraph)
}
},
}
@@ -71,6 +91,346 @@ func showMolecule(subgraph *MoleculeSubgraph) {
fmt.Println()
}
// ParallelInfo holds parallel analysis information for a step
type ParallelInfo struct {
StepID string `json:"step_id"`
Status string `json:"status"`
IsReady bool `json:"is_ready"` // Can start now (no blocking deps)
ParallelGroup string `json:"parallel_group"` // Group ID (steps with same group can parallelize)
BlockedBy []string `json:"blocked_by"` // IDs of open steps blocking this one
Blocks []string `json:"blocks"` // IDs of steps this one blocks
CanParallel []string `json:"can_parallel"` // IDs of steps that can run in parallel with this
}
// ParallelAnalysis holds the complete parallel analysis for a molecule
type ParallelAnalysis struct {
MoleculeID string `json:"molecule_id"`
TotalSteps int `json:"total_steps"`
ReadySteps int `json:"ready_steps"`
ParallelGroups map[string][]string `json:"parallel_groups"` // group ID -> step IDs
Steps map[string]*ParallelInfo `json:"steps"`
}
// analyzeMoleculeParallel performs parallel detection on a molecule subgraph.
// Returns analysis of which steps can run in parallel.
func analyzeMoleculeParallel(subgraph *MoleculeSubgraph) *ParallelAnalysis {
analysis := &ParallelAnalysis{
MoleculeID: subgraph.Root.ID,
TotalSteps: len(subgraph.Issues),
ParallelGroups: make(map[string][]string),
Steps: make(map[string]*ParallelInfo),
}
// Build dependency maps
// blockedBy[id] = set of issue IDs that block this issue
// blocks[id] = set of issue IDs that this issue blocks
blockedBy := make(map[string]map[string]bool)
blocks := make(map[string]map[string]bool)
for _, issue := range subgraph.Issues {
blockedBy[issue.ID] = make(map[string]bool)
blocks[issue.ID] = make(map[string]bool)
}
// Process dependencies to find blocking relationships
for _, dep := range subgraph.Dependencies {
// Only blocking dependencies affect parallel execution
if dep.Type == types.DepBlocks || dep.Type == types.DepConditionalBlocks {
// dep.IssueID depends on (is blocked by) dep.DependsOnID
if _, ok := blockedBy[dep.IssueID]; ok {
blockedBy[dep.IssueID][dep.DependsOnID] = true
}
if _, ok := blocks[dep.DependsOnID]; ok {
blocks[dep.DependsOnID][dep.IssueID] = true
}
}
}
// Identify which steps are ready (no open blockers)
readySteps := make(map[string]bool)
for _, issue := range subgraph.Issues {
info := &ParallelInfo{
StepID: issue.ID,
Status: string(issue.Status),
BlockedBy: []string{},
Blocks: []string{},
}
// Check what blocks this step
for blockerID := range blockedBy[issue.ID] {
blocker := subgraph.IssueMap[blockerID]
if blocker != nil && blocker.Status != types.StatusClosed {
info.BlockedBy = append(info.BlockedBy, blockerID)
}
}
// Check what this step blocks
for blockedID := range blocks[issue.ID] {
info.Blocks = append(info.Blocks, blockedID)
}
// A step is ready if it's open/in_progress and has no open blockers
info.IsReady = (issue.Status == types.StatusOpen || issue.Status == types.StatusInProgress) &&
len(info.BlockedBy) == 0
if info.IsReady {
readySteps[issue.ID] = true
analysis.ReadySteps++
}
// Sort for consistent output
sort.Strings(info.BlockedBy)
sort.Strings(info.Blocks)
analysis.Steps[issue.ID] = info
}
// Identify parallel groups: steps that can run concurrently
// Two steps can parallelize if:
// 1. Both are ready (or will be ready at same time)
// 2. Neither blocks the other (directly or transitively)
// 3. They share the same blocking depth (distance from root)
// Calculate blocking depth for each step
depths := calculateBlockingDepths(subgraph, blockedBy)
// Group steps by depth - steps at same depth can potentially parallelize
depthGroups := make(map[int][]string)
for id, depth := range depths {
depthGroups[depth] = append(depthGroups[depth], id)
}
// For each depth level, identify parallel groups
groupCounter := 0
for depth := 0; depth <= len(subgraph.Issues); depth++ {
stepsAtDepth := depthGroups[depth]
if len(stepsAtDepth) == 0 {
continue
}
// Group steps that can parallelize (no blocking between them)
// Use union-find approach: start with each step in its own group
parent := make(map[string]string)
for _, id := range stepsAtDepth {
parent[id] = id
}
find := func(x string) string {
for parent[x] != x {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
union := func(x, y string) {
px, py := find(x), find(y)
if px != py {
parent[px] = py
}
}
// Merge steps that CAN parallelize (no mutual blocking)
for i, id1 := range stepsAtDepth {
for j := i + 1; j < len(stepsAtDepth); j++ {
id2 := stepsAtDepth[j]
// Can parallelize if neither blocks the other
if !blocks[id1][id2] && !blocks[id2][id1] &&
!blockedBy[id1][id2] && !blockedBy[id2][id1] {
union(id1, id2)
}
}
}
// Collect groups
groups := make(map[string][]string)
for _, id := range stepsAtDepth {
root := find(id)
groups[root] = append(groups[root], id)
}
// Assign group names and record can_parallel relationships
for _, members := range groups {
if len(members) > 1 {
groupCounter++
groupName := fmt.Sprintf("group-%d", groupCounter)
analysis.ParallelGroups[groupName] = members
// Update each step's parallel info
for _, id := range members {
info := analysis.Steps[id]
info.ParallelGroup = groupName
// Record all other members as can_parallel
for _, otherId := range members {
if otherId != id {
info.CanParallel = append(info.CanParallel, otherId)
}
}
sort.Strings(info.CanParallel)
}
}
}
}
return analysis
}
// calculateBlockingDepths calculates the "blocking depth" of each step.
// Depth 0 = no blockers, Depth 1 = blocked by depth-0 steps, etc.
func calculateBlockingDepths(subgraph *MoleculeSubgraph, blockedBy map[string]map[string]bool) map[string]int {
depths := make(map[string]int)
visited := make(map[string]bool)
var calculateDepth func(id string) int
calculateDepth = func(id string) int {
if d, ok := depths[id]; ok {
return d
}
if visited[id] {
// Cycle detected, return 0 to break
return 0
}
visited[id] = true
maxBlockerDepth := -1
for blockerID := range blockedBy[id] {
// Only count open blockers
blocker := subgraph.IssueMap[blockerID]
if blocker != nil && blocker.Status != types.StatusClosed {
blockerDepth := calculateDepth(blockerID)
if blockerDepth > maxBlockerDepth {
maxBlockerDepth = blockerDepth
}
}
}
depth := maxBlockerDepth + 1
depths[id] = depth
return depth
}
for _, issue := range subgraph.Issues {
calculateDepth(issue.ID)
}
return depths
}
// showMoleculeWithParallel displays molecule structure with parallel annotations
func showMoleculeWithParallel(subgraph *MoleculeSubgraph) {
analysis := analyzeMoleculeParallel(subgraph)
if jsonOutput {
outputJSON(map[string]interface{}{
"root": subgraph.Root,
"issues": subgraph.Issues,
"dependencies": subgraph.Dependencies,
"variables": extractAllVariables(subgraph),
"parallel": analysis,
})
return
}
fmt.Printf("\n%s Molecule: %s\n", ui.RenderAccent("🧪"), subgraph.Root.Title)
fmt.Printf(" ID: %s\n", subgraph.Root.ID)
fmt.Printf(" Steps: %d (%d ready)\n", analysis.TotalSteps, analysis.ReadySteps)
// Show parallel groups summary
if len(analysis.ParallelGroups) > 0 {
fmt.Printf("\n%s Parallel Groups:\n", ui.RenderPass("⚡"))
for groupName, members := range analysis.ParallelGroups {
fmt.Printf(" %s: %s\n", groupName, strings.Join(members, ", "))
}
}
vars := extractAllVariables(subgraph)
if len(vars) > 0 {
fmt.Printf("\n%s Variables:\n", ui.RenderWarn("📝"))
for _, v := range vars {
fmt.Printf(" {{%s}}\n", v)
}
}
fmt.Printf("\n%s Structure:\n", ui.RenderPass("🌲"))
printMoleculeTreeWithParallel(subgraph, analysis, subgraph.Root.ID, 0, true)
fmt.Println()
}
// printMoleculeTreeWithParallel prints the molecule structure with parallel annotations
func printMoleculeTreeWithParallel(subgraph *MoleculeSubgraph, analysis *ParallelAnalysis, parentID string, depth int, isRoot bool) {
indent := strings.Repeat(" ", depth)
// Print root with parallel info
if isRoot {
rootInfo := analysis.Steps[subgraph.Root.ID]
annotation := getParallelAnnotation(rootInfo)
fmt.Printf("%s %s%s\n", indent, subgraph.Root.Title, annotation)
}
// Find children of this parent
var children []*types.Issue
for _, dep := range subgraph.Dependencies {
if dep.DependsOnID == parentID && dep.Type == types.DepParentChild {
if child, ok := subgraph.IssueMap[dep.IssueID]; ok {
children = append(children, child)
}
}
}
// Print children
for i, child := range children {
connector := "├──"
if i == len(children)-1 {
connector = "└──"
}
info := analysis.Steps[child.ID]
annotation := getParallelAnnotation(info)
fmt.Printf("%s %s %s%s\n", indent, connector, child.Title, annotation)
printMoleculeTreeWithParallel(subgraph, analysis, child.ID, depth+1, false)
}
}
// getParallelAnnotation returns the annotation string for a step's parallel status
func getParallelAnnotation(info *ParallelInfo) string {
if info == nil {
return ""
}
parts := []string{}
// Status indicator
switch info.Status {
case string(types.StatusOpen):
if info.IsReady {
parts = append(parts, ui.RenderPass("ready"))
} else {
parts = append(parts, ui.RenderFail("blocked"))
}
case string(types.StatusInProgress):
parts = append(parts, ui.RenderWarn("in_progress"))
case string(types.StatusClosed):
parts = append(parts, ui.RenderPass("completed"))
}
// Parallel group
if info.ParallelGroup != "" {
parts = append(parts, ui.RenderAccent(info.ParallelGroup))
}
// Blocking info
if len(info.BlockedBy) > 0 {
parts = append(parts, fmt.Sprintf("needs: %s", strings.Join(info.BlockedBy, ", ")))
}
if len(parts) == 0 {
return ""
}
return " [" + strings.Join(parts, " | ") + "]"
}
func init() {
molShowCmd.Flags().BoolVarP(&molShowParallel, "parallel", "p", false, "Show parallel step analysis")
molCmd.AddCommand(molShowCmd)
}

View File

@@ -2345,3 +2345,304 @@ func TestBondProtoMolMultipleArms(t *testing.T) {
t.Errorf("Nux issue not found: %v", err)
}
}
// =============================================================================
// Parallel Detection Tests (bd-xo1o.4)
// =============================================================================
// TestAnalyzeMoleculeParallelNoBlocking tests parallel detection with no blocking deps
func TestAnalyzeMoleculeParallelNoBlocking(t *testing.T) {
// Create a simple molecule with parallel children (no blocking deps between them)
root := &types.Issue{
ID: "mol-test",
Title: "Test Molecule",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
child1 := &types.Issue{
ID: "mol-test.step1",
Title: "Step 1",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
child2 := &types.Issue{
ID: "mol-test.step2",
Title: "Step 2",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, child1, child2},
IssueMap: map[string]*types.Issue{
root.ID: root,
child1.ID: child1,
child2.ID: child2,
},
Dependencies: []*types.Dependency{
{IssueID: child1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: child2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
},
}
analysis := analyzeMoleculeParallel(subgraph)
// All 3 should be ready (root + 2 children with no blocking deps)
if analysis.ReadySteps != 3 {
t.Errorf("ReadySteps = %d, want 3", analysis.ReadySteps)
}
// Children should be in the same parallel group
step1Info := analysis.Steps[child1.ID]
step2Info := analysis.Steps[child2.ID]
if step1Info.ParallelGroup == "" {
t.Error("Step1 should be in a parallel group")
}
if step1Info.ParallelGroup != step2Info.ParallelGroup {
t.Errorf("Step1 and Step2 should be in same parallel group: %s vs %s",
step1Info.ParallelGroup, step2Info.ParallelGroup)
}
// Check can_parallel
found := false
for _, id := range step1Info.CanParallel {
if id == child2.ID {
found = true
break
}
}
if !found {
t.Errorf("Step1.CanParallel should contain Step2.ID")
}
}
// TestAnalyzeMoleculeParallelWithBlocking tests parallel detection with blocking deps
func TestAnalyzeMoleculeParallelWithBlocking(t *testing.T) {
// Create a sequential molecule: step1 blocks step2
root := &types.Issue{
ID: "mol-seq",
Title: "Sequential Molecule",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
step1 := &types.Issue{
ID: "mol-seq.step1",
Title: "Step 1",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
step2 := &types.Issue{
ID: "mol-seq.step2",
Title: "Step 2 (blocked by Step 1)",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, step1, step2},
IssueMap: map[string]*types.Issue{
root.ID: root,
step1.ID: step1,
step2.ID: step2,
},
Dependencies: []*types.Dependency{
{IssueID: step1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: step1.ID, Type: types.DepBlocks}, // step2 blocked by step1
},
}
analysis := analyzeMoleculeParallel(subgraph)
// Only root and step1 should be ready (step2 is blocked)
if analysis.ReadySteps != 2 {
t.Errorf("ReadySteps = %d, want 2 (step2 blocked)", analysis.ReadySteps)
}
step1Info := analysis.Steps[step1.ID]
step2Info := analysis.Steps[step2.ID]
if !step1Info.IsReady {
t.Error("Step1 should be ready")
}
if step2Info.IsReady {
t.Error("Step2 should NOT be ready (blocked by step1)")
}
if len(step2Info.BlockedBy) != 1 || step2Info.BlockedBy[0] != step1.ID {
t.Errorf("Step2.BlockedBy = %v, want [%s]", step2Info.BlockedBy, step1.ID)
}
// Step1 and Step2 should NOT be in the same parallel group
if step1Info.ParallelGroup != "" && step1Info.ParallelGroup == step2Info.ParallelGroup {
t.Error("Blocking steps should NOT be in the same parallel group")
}
}
// TestAnalyzeMoleculeParallelCompletedBlockers tests that completed steps don't block
func TestAnalyzeMoleculeParallelCompletedBlockers(t *testing.T) {
// Create molecule where step1 is completed, so step2 should be ready
root := &types.Issue{
ID: "mol-done",
Title: "Molecule with completed step",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
step1 := &types.Issue{
ID: "mol-done.step1",
Title: "Step 1 (completed)",
Status: types.StatusClosed, // Completed!
IssueType: types.TypeTask,
}
step2 := &types.Issue{
ID: "mol-done.step2",
Title: "Step 2 (depends on step1)",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, step1, step2},
IssueMap: map[string]*types.Issue{
root.ID: root,
step1.ID: step1,
step2.ID: step2,
},
Dependencies: []*types.Dependency{
{IssueID: step1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: step2.ID, DependsOnID: step1.ID, Type: types.DepBlocks},
},
}
analysis := analyzeMoleculeParallel(subgraph)
step2Info := analysis.Steps[step2.ID]
// Step2 should be ready since step1 is closed
if !step2Info.IsReady {
t.Error("Step2 should be ready (step1 is completed)")
}
if len(step2Info.BlockedBy) != 0 {
t.Errorf("Step2.BlockedBy = %v, want empty (step1 completed)", step2Info.BlockedBy)
}
}
// TestAnalyzeMoleculeParallelMultipleArms tests parallel detection across bonded arms
func TestAnalyzeMoleculeParallelMultipleArms(t *testing.T) {
// Create molecule with two arms that can run in parallel
root := &types.Issue{
ID: "patrol",
Title: "Patrol",
Status: types.StatusOpen,
IssueType: types.TypeEpic,
}
armAce := &types.Issue{
ID: "patrol.arm-ace",
Title: "Arm: ace",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
armNux := &types.Issue{
ID: "patrol.arm-nux",
Title: "Arm: nux",
Status: types.StatusOpen,
IssueType: types.TypeTask,
}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, armAce, armNux},
IssueMap: map[string]*types.Issue{
root.ID: root,
armAce.ID: armAce,
armNux.ID: armNux,
},
Dependencies: []*types.Dependency{
{IssueID: armAce.ID, DependsOnID: root.ID, Type: types.DepParentChild},
{IssueID: armNux.ID, DependsOnID: root.ID, Type: types.DepParentChild},
// No blocking deps between arms
},
}
analysis := analyzeMoleculeParallel(subgraph)
// All 3 should be ready
if analysis.ReadySteps != 3 {
t.Errorf("ReadySteps = %d, want 3", analysis.ReadySteps)
}
// Arms should be in the same parallel group
aceInfo := analysis.Steps[armAce.ID]
nuxInfo := analysis.Steps[armNux.ID]
if aceInfo.ParallelGroup == "" {
t.Error("arm-ace should be in a parallel group")
}
if aceInfo.ParallelGroup != nuxInfo.ParallelGroup {
t.Errorf("Arms should be in same parallel group: %s vs %s",
aceInfo.ParallelGroup, nuxInfo.ParallelGroup)
}
// Should have at least one parallel group with both arms
foundGroup := false
for _, members := range analysis.ParallelGroups {
hasAce := false
hasNux := false
for _, id := range members {
if id == armAce.ID {
hasAce = true
}
if id == armNux.ID {
hasNux = true
}
}
if hasAce && hasNux {
foundGroup = true
break
}
}
if !foundGroup {
t.Error("Should have a parallel group containing both arms")
}
}
// TestCalculateBlockingDepths tests the depth calculation
func TestCalculateBlockingDepths(t *testing.T) {
// Create chain: root -> step1 -> step2 -> step3
root := &types.Issue{ID: "root", Status: types.StatusOpen}
step1 := &types.Issue{ID: "step1", Status: types.StatusOpen}
step2 := &types.Issue{ID: "step2", Status: types.StatusOpen}
step3 := &types.Issue{ID: "step3", Status: types.StatusOpen}
subgraph := &MoleculeSubgraph{
Root: root,
Issues: []*types.Issue{root, step1, step2, step3},
IssueMap: map[string]*types.Issue{"root": root, "step1": step1, "step2": step2, "step3": step3},
}
blockedBy := map[string]map[string]bool{
"root": {},
"step1": {"root": true},
"step2": {"step1": true},
"step3": {"step2": true},
}
depths := calculateBlockingDepths(subgraph, blockedBy)
if depths["root"] != 0 {
t.Errorf("root depth = %d, want 0", depths["root"])
}
if depths["step1"] != 1 {
t.Errorf("step1 depth = %d, want 1", depths["step1"])
}
if depths["step2"] != 2 {
t.Errorf("step2 depth = %d, want 2", depths["step2"])
}
if depths["step3"] != 3 {
t.Errorf("step3 depth = %d, want 3", depths["step3"])
}
}

View File

@@ -12,11 +12,26 @@ import (
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/util"
"github.com/steveyegge/beads/internal/utils"
)
var readyCmd = &cobra.Command{
Use: "ready",
Short: "Show ready work (no blockers, open or in_progress)",
Long: `Show ready work (issues with no blockers that are open or in_progress).
Use --mol to filter to a specific molecule's steps:
bd ready --mol bd-patrol # Show ready steps within molecule
This is useful for agents executing molecules to see which steps can run next.`,
Run: func(cmd *cobra.Command, args []string) {
// Handle molecule-specific ready query
molID, _ := cmd.Flags().GetString("mol")
if molID != "" {
runMoleculeReady(cmd, molID)
return
}
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
unassigned, _ := cmd.Flags().GetBool("unassigned")
@@ -252,6 +267,139 @@ var blockedCmd = &cobra.Command{
},
}
// runMoleculeReady shows ready steps within a specific molecule
func runMoleculeReady(cmd *cobra.Command, molIDArg string) {
ctx := rootCtx
// Molecule-ready requires direct store access for subgraph loading
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: bd ready --mol requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon ready --mol %s\n", molIDArg)
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
// Resolve molecule ID
moleculeID, err := utils.ResolvePartialID(ctx, store, molIDArg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", molIDArg)
os.Exit(1)
}
// Load molecule subgraph
subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
os.Exit(1)
}
// Get parallel analysis to find ready steps
analysis := analyzeMoleculeParallel(subgraph)
// Collect ready steps
var readySteps []*MoleculeReadyStep
for _, issue := range subgraph.Issues {
info := analysis.Steps[issue.ID]
if info != nil && info.IsReady {
readySteps = append(readySteps, &MoleculeReadyStep{
Issue: issue,
ParallelInfo: info,
ParallelGroup: info.ParallelGroup,
})
}
}
if jsonOutput {
output := MoleculeReadyOutput{
MoleculeID: moleculeID,
MoleculeTitle: subgraph.Root.Title,
TotalSteps: analysis.TotalSteps,
ReadySteps: len(readySteps),
Steps: readySteps,
ParallelGroups: analysis.ParallelGroups,
}
outputJSON(output)
return
}
// Human-readable output
fmt.Printf("\n%s Ready steps in molecule: %s\n", ui.RenderAccent("🧪"), subgraph.Root.Title)
fmt.Printf(" ID: %s\n", moleculeID)
fmt.Printf(" Total: %d steps, %d ready\n", analysis.TotalSteps, len(readySteps))
if len(readySteps) == 0 {
fmt.Printf("\n%s No ready steps (all blocked or completed)\n\n", ui.RenderWarn("✨"))
return
}
// Show parallel groups if any
if len(analysis.ParallelGroups) > 0 {
fmt.Printf("\n%s Parallel Groups:\n", ui.RenderPass("⚡"))
for groupName, members := range analysis.ParallelGroups {
// Check if any members are ready
readyInGroup := 0
for _, id := range members {
if info := analysis.Steps[id]; info != nil && info.IsReady {
readyInGroup++
}
}
if readyInGroup > 0 {
fmt.Printf(" %s: %d ready\n", groupName, readyInGroup)
}
}
}
fmt.Printf("\n%s Ready steps:\n\n", ui.RenderPass("📋"))
for i, step := range readySteps {
// Show parallel group if in one
groupAnnotation := ""
if step.ParallelGroup != "" {
groupAnnotation = fmt.Sprintf(" [%s]", ui.RenderAccent(step.ParallelGroup))
}
fmt.Printf("%d. [%s] [%s] %s: %s%s\n", i+1,
ui.RenderPriority(step.Issue.Priority),
ui.RenderType(string(step.Issue.IssueType)),
ui.RenderID(step.Issue.ID),
step.Issue.Title,
groupAnnotation)
// Show what this step can parallelize with
if len(step.ParallelInfo.CanParallel) > 0 {
readyParallel := []string{}
for _, pID := range step.ParallelInfo.CanParallel {
if pInfo := analysis.Steps[pID]; pInfo != nil && pInfo.IsReady {
readyParallel = append(readyParallel, pID)
}
}
if len(readyParallel) > 0 {
fmt.Printf(" Can run with: %v\n", readyParallel)
}
}
}
fmt.Println()
}
// MoleculeReadyStep holds a ready step with its parallel info
type MoleculeReadyStep struct {
Issue *types.Issue `json:"issue"`
ParallelInfo *ParallelInfo `json:"parallel_info"`
ParallelGroup string `json:"parallel_group,omitempty"`
}
// MoleculeReadyOutput is the JSON output for bd ready --mol
type MoleculeReadyOutput struct {
MoleculeID string `json:"molecule_id"`
MoleculeTitle string `json:"molecule_title"`
TotalSteps int `json:"total_steps"`
ReadySteps int `json:"ready_steps"`
Steps []*MoleculeReadyStep `json:"steps"`
ParallelGroups map[string][]string `json:"parallel_groups"`
}
func init() {
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
@@ -261,6 +409,7 @@ func init() {
readyCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
readyCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)")
readyCmd.Flags().String("mol", "", "Filter to steps within a specific molecule")
rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd)
}