feat: add bd swarm status command (bd-5x0j)
Show current swarm status computed from beads: - Completed: closed issues with timestamps - Active: in_progress issues with assignees - Ready: open issues with all deps satisfied - Blocked: open issues waiting on deps - Progress percentage State is COMPUTED from beads, not stored separately. Supports --json for programmatic use. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+311
@@ -517,9 +517,320 @@ func renderSwarmAnalysis(analysis *SwarmAnalysis) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SwarmStatus holds the current status of a swarm (computed from beads).
|
||||||
|
type SwarmStatus struct {
|
||||||
|
EpicID string `json:"epic_id"`
|
||||||
|
EpicTitle string `json:"epic_title"`
|
||||||
|
TotalIssues int `json:"total_issues"`
|
||||||
|
Completed []StatusIssue `json:"completed"`
|
||||||
|
Active []StatusIssue `json:"active"`
|
||||||
|
Ready []StatusIssue `json:"ready"`
|
||||||
|
Blocked []StatusIssue `json:"blocked"`
|
||||||
|
Progress float64 `json:"progress_percent"`
|
||||||
|
ActiveCount int `json:"active_count"`
|
||||||
|
ReadyCount int `json:"ready_count"`
|
||||||
|
BlockedCount int `json:"blocked_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusIssue represents an issue in swarm status output.
|
||||||
|
type StatusIssue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Assignee string `json:"assignee,omitempty"`
|
||||||
|
BlockedBy []string `json:"blocked_by,omitempty"`
|
||||||
|
ClosedAt string `json:"closed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var swarmStatusCmd = &cobra.Command{
|
||||||
|
Use: "status [epic-id]",
|
||||||
|
Short: "Show current swarm status",
|
||||||
|
Long: `Show the current status of a swarm, computed from beads.
|
||||||
|
|
||||||
|
Displays issues grouped by state:
|
||||||
|
- Completed: Closed issues
|
||||||
|
- Active: Issues currently in_progress (with assignee)
|
||||||
|
- Ready: Open issues with all dependencies satisfied
|
||||||
|
- Blocked: Open issues waiting on dependencies
|
||||||
|
|
||||||
|
The status is COMPUTED from beads, not stored separately.
|
||||||
|
If beads changes, status changes.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd swarm status gt-epic-123 # Show swarm status
|
||||||
|
bd swarm status gt-epic-123 --json # Machine-readable output`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
ctx := rootCtx
|
||||||
|
|
||||||
|
// Swarm commands require direct store access
|
||||||
|
if store == nil {
|
||||||
|
if daemonClient != nil {
|
||||||
|
var err error
|
||||||
|
store, err = sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
FatalErrorRespectJSON("failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = store.Close() }()
|
||||||
|
} else {
|
||||||
|
FatalErrorRespectJSON("no database connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve epic ID
|
||||||
|
epicID, err := utils.ResolvePartialID(ctx, store, args[0])
|
||||||
|
if err != nil {
|
||||||
|
FatalErrorRespectJSON("epic '%s' not found: %v", args[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the epic
|
||||||
|
epic, err := store.GetIssue(ctx, epicID)
|
||||||
|
if err != nil {
|
||||||
|
FatalErrorRespectJSON("failed to get epic: %v", err)
|
||||||
|
}
|
||||||
|
if epic == nil {
|
||||||
|
FatalErrorRespectJSON("epic '%s' not found", epicID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's an epic or molecule
|
||||||
|
if epic.IssueType != types.TypeEpic && epic.IssueType != types.TypeMolecule {
|
||||||
|
FatalErrorRespectJSON("'%s' is not an epic or molecule (type: %s)", epicID, epic.IssueType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get swarm status
|
||||||
|
status, err := getSwarmStatus(ctx, store, epic)
|
||||||
|
if err != nil {
|
||||||
|
FatalErrorRespectJSON("failed to get swarm status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
renderSwarmStatus(status)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSwarmStatus computes current swarm status from beads.
|
||||||
|
func getSwarmStatus(ctx context.Context, s interface {
|
||||||
|
GetIssue(context.Context, string) (*types.Issue, error)
|
||||||
|
GetDependents(context.Context, string) ([]*types.Issue, error)
|
||||||
|
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
|
||||||
|
}, epic *types.Issue) (*SwarmStatus, error) {
|
||||||
|
status := &SwarmStatus{
|
||||||
|
EpicID: epic.ID,
|
||||||
|
EpicTitle: epic.Title,
|
||||||
|
Completed: []StatusIssue{},
|
||||||
|
Active: []StatusIssue{},
|
||||||
|
Ready: []StatusIssue{},
|
||||||
|
Blocked: []StatusIssue{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all issues that depend on the epic (children)
|
||||||
|
allDependents, err := s.GetDependents(ctx, epic.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only parent-child relationships
|
||||||
|
var childIssues []*types.Issue
|
||||||
|
for _, dependent := range allDependents {
|
||||||
|
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, dep := range deps {
|
||||||
|
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
||||||
|
childIssues = append(childIssues, dependent)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status.TotalIssues = len(childIssues)
|
||||||
|
if len(childIssues) == 0 {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build set of child IDs for filtering
|
||||||
|
childIDSet := make(map[string]bool)
|
||||||
|
for _, issue := range childIssues {
|
||||||
|
childIDSet[issue.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dependency map (within epic children only)
|
||||||
|
dependsOn := make(map[string][]string)
|
||||||
|
for _, issue := range childIssues {
|
||||||
|
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, dep := range deps {
|
||||||
|
// Skip parent-child to epic itself
|
||||||
|
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only track blocking dependencies within children
|
||||||
|
if !dep.Type.AffectsReadyWork() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if childIDSet[dep.DependsOnID] {
|
||||||
|
dependsOn[issue.ID] = append(dependsOn[issue.ID], dep.DependsOnID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorize each issue
|
||||||
|
for _, issue := range childIssues {
|
||||||
|
si := StatusIssue{
|
||||||
|
ID: issue.ID,
|
||||||
|
Title: issue.Title,
|
||||||
|
Assignee: issue.Assignee,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch issue.Status {
|
||||||
|
case types.StatusClosed:
|
||||||
|
if issue.ClosedAt != nil {
|
||||||
|
si.ClosedAt = issue.ClosedAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
status.Completed = append(status.Completed, si)
|
||||||
|
|
||||||
|
case types.StatusInProgress:
|
||||||
|
status.Active = append(status.Active, si)
|
||||||
|
|
||||||
|
default: // open or other
|
||||||
|
// Check if blocked by open dependencies
|
||||||
|
deps := dependsOn[issue.ID]
|
||||||
|
var blockers []string
|
||||||
|
for _, depID := range deps {
|
||||||
|
depIssue, _ := s.GetIssue(ctx, depID)
|
||||||
|
if depIssue != nil && depIssue.Status != types.StatusClosed {
|
||||||
|
blockers = append(blockers, depID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(blockers) > 0 {
|
||||||
|
si.BlockedBy = blockers
|
||||||
|
status.Blocked = append(status.Blocked, si)
|
||||||
|
} else {
|
||||||
|
status.Ready = append(status.Ready, si)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each category by ID for consistent output
|
||||||
|
sort.Slice(status.Completed, func(i, j int) bool {
|
||||||
|
return status.Completed[i].ID < status.Completed[j].ID
|
||||||
|
})
|
||||||
|
sort.Slice(status.Active, func(i, j int) bool {
|
||||||
|
return status.Active[i].ID < status.Active[j].ID
|
||||||
|
})
|
||||||
|
sort.Slice(status.Ready, func(i, j int) bool {
|
||||||
|
return status.Ready[i].ID < status.Ready[j].ID
|
||||||
|
})
|
||||||
|
sort.Slice(status.Blocked, func(i, j int) bool {
|
||||||
|
return status.Blocked[i].ID < status.Blocked[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute counts and progress
|
||||||
|
status.ActiveCount = len(status.Active)
|
||||||
|
status.ReadyCount = len(status.Ready)
|
||||||
|
status.BlockedCount = len(status.Blocked)
|
||||||
|
if status.TotalIssues > 0 {
|
||||||
|
status.Progress = float64(len(status.Completed)) / float64(status.TotalIssues) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSwarmStatus outputs human-readable swarm status.
|
||||||
|
func renderSwarmStatus(status *SwarmStatus) {
|
||||||
|
fmt.Printf("\n%s Ready Front Analysis: %s\n\n", ui.RenderAccent("🐝"), status.EpicTitle)
|
||||||
|
|
||||||
|
// Completed
|
||||||
|
fmt.Printf("Completed: ")
|
||||||
|
if len(status.Completed) == 0 {
|
||||||
|
fmt.Printf("(none)\n")
|
||||||
|
} else {
|
||||||
|
for i, issue := range status.Completed {
|
||||||
|
if i > 0 {
|
||||||
|
fmt.Printf(" ")
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s\n", ui.RenderPass("✓"), ui.RenderID(issue.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active
|
||||||
|
fmt.Printf("Active: ")
|
||||||
|
if len(status.Active) == 0 {
|
||||||
|
fmt.Printf("(none)\n")
|
||||||
|
} else {
|
||||||
|
var parts []string
|
||||||
|
for _, issue := range status.Active {
|
||||||
|
part := fmt.Sprintf("⟳ %s", issue.ID)
|
||||||
|
if issue.Assignee != "" {
|
||||||
|
part += fmt.Sprintf(" [%s]", issue.Assignee)
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", strings.Join(parts, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready
|
||||||
|
fmt.Printf("Ready: ")
|
||||||
|
if len(status.Ready) == 0 {
|
||||||
|
if len(status.Blocked) > 0 {
|
||||||
|
// Find what's blocking
|
||||||
|
needed := make(map[string]bool)
|
||||||
|
for _, b := range status.Blocked {
|
||||||
|
for _, dep := range b.BlockedBy {
|
||||||
|
needed[dep] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var neededList []string
|
||||||
|
for dep := range needed {
|
||||||
|
neededList = append(neededList, dep)
|
||||||
|
}
|
||||||
|
sort.Strings(neededList)
|
||||||
|
fmt.Printf("(none - waiting for %s)\n", strings.Join(neededList, ", "))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("(none)\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var parts []string
|
||||||
|
for _, issue := range status.Ready {
|
||||||
|
parts = append(parts, fmt.Sprintf("○ %s", issue.ID))
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", strings.Join(parts, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked
|
||||||
|
fmt.Printf("Blocked: ")
|
||||||
|
if len(status.Blocked) == 0 {
|
||||||
|
fmt.Printf("(none)\n")
|
||||||
|
} else {
|
||||||
|
for i, issue := range status.Blocked {
|
||||||
|
if i > 0 {
|
||||||
|
fmt.Printf(" ")
|
||||||
|
}
|
||||||
|
blockerStr := strings.Join(issue.BlockedBy, ", ")
|
||||||
|
fmt.Printf("◌ %s (needs %s)\n", issue.ID, blockerStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress summary
|
||||||
|
fmt.Printf("\nProgress: %d/%d complete", len(status.Completed), status.TotalIssues)
|
||||||
|
if status.ActiveCount > 0 {
|
||||||
|
fmt.Printf(", %d/%d active", status.ActiveCount, status.TotalIssues)
|
||||||
|
}
|
||||||
|
fmt.Printf(" (%.0f%%)\n\n", status.Progress)
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
swarmValidateCmd.Flags().Bool("verbose", false, "Include detailed issue graph in output")
|
swarmValidateCmd.Flags().Bool("verbose", false, "Include detailed issue graph in output")
|
||||||
|
|
||||||
swarmCmd.AddCommand(swarmValidateCmd)
|
swarmCmd.AddCommand(swarmValidateCmd)
|
||||||
|
swarmCmd.AddCommand(swarmStatusCmd)
|
||||||
rootCmd.AddCommand(swarmCmd)
|
rootCmd.AddCommand(swarmCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user