feat: add molecule navigation commands (bd-sal9, bd-ieyy)

Add bd mol current: Shows current position in molecule workflow
- Displays all steps with status indicators (done/current/ready/blocked/pending)
- Infers molecule from in_progress issues when no ID given
- Supports --for flag to check another agent's molecules

Add bd close --continue: Auto-advances to next molecule step
- After closing, finds parent molecule and next ready step
- Auto-claims next step by default (--no-auto to skip)
- Shows molecule completion message when all steps closed

🤖 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-22 17:37:13 -08:00
parent 11dd84a579
commit 808c33b7a0
2 changed files with 525 additions and 0 deletions

486
cmd/bd/mol_current.go Normal file
View File

@@ -0,0 +1,486 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
// MoleculeProgress holds the progress information for a molecule
type MoleculeProgress struct {
MoleculeID string `json:"molecule_id"`
MoleculeTitle string `json:"molecule_title"`
Assignee string `json:"assignee,omitempty"`
CurrentStep *types.Issue `json:"current_step,omitempty"`
NextStep *types.Issue `json:"next_step,omitempty"`
Steps []*StepStatus `json:"steps"`
Completed int `json:"completed"`
Total int `json:"total"`
}
// StepStatus represents the status of a step in a molecule
type StepStatus struct {
Issue *types.Issue `json:"issue"`
Status string `json:"status"` // "done", "current", "ready", "blocked", "pending"
IsCurrent bool `json:"is_current"` // true if this is the in_progress step
}
var molCurrentCmd = &cobra.Command{
Use: "current [molecule-id]",
Short: "Show current position in molecule workflow",
Long: `Show where you are in a molecule workflow.
If molecule-id is given, show status for that molecule.
If not given, infer from in_progress issues assigned to current agent.
The output shows all steps with status indicators:
[done] - Step is complete (closed)
[current] - Step is in_progress (you are here)
[ready] - Step is ready to start (unblocked)
[blocked] - Step is blocked by dependencies
[pending] - Step is waiting`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
forAgent, _ := cmd.Flags().GetString("for")
// Determine who we're looking for
agent := forAgent
if agent == "" {
agent = actor // Default to current user/agent
}
// mol current requires direct store access for subgraph loading
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: mol current requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol current\n")
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
var molecules []*MoleculeProgress
if len(args) == 1 {
// Explicit molecule ID given
moleculeID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", args[0])
os.Exit(1)
}
progress, err := getMoleculeProgress(ctx, store, moleculeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
os.Exit(1)
}
molecules = append(molecules, progress)
} else {
// Infer from in_progress issues
molecules = findInProgressMolecules(ctx, store, agent)
if len(molecules) == 0 {
if jsonOutput {
outputJSON([]interface{}{})
return
}
fmt.Printf("No molecules in progress")
if agent != "" {
fmt.Printf(" for %s", agent)
}
fmt.Println(".")
fmt.Println("\nTo start work on a molecule:")
fmt.Println(" bd mol run <proto-id> # Spawn and start a new molecule")
fmt.Println(" bd update <step-id> --status in_progress # Claim a step")
return
}
}
if jsonOutput {
outputJSON(molecules)
return
}
// Human-readable output
for i, mol := range molecules {
if i > 0 {
fmt.Println()
}
printMoleculeProgress(mol)
}
},
}
// getMoleculeProgress loads a molecule and computes progress
func getMoleculeProgress(ctx context.Context, s storage.Storage, moleculeID string) (*MoleculeProgress, error) {
subgraph, err := loadTemplateSubgraph(ctx, s, moleculeID)
if err != nil {
return nil, err
}
progress := &MoleculeProgress{
MoleculeID: subgraph.Root.ID,
MoleculeTitle: subgraph.Root.Title,
Assignee: subgraph.Root.Assignee,
Total: len(subgraph.Issues) - 1, // Exclude root
}
// Get ready issues for this molecule
readyIDs := make(map[string]bool)
readyIssues, err := s.GetReadyWork(ctx, types.WorkFilter{})
if err == nil {
for _, issue := range readyIssues {
readyIDs[issue.ID] = true
}
}
// Build step status list (exclude root)
var steps []*StepStatus
for _, issue := range subgraph.Issues {
if issue.ID == subgraph.Root.ID {
continue // Skip root
}
step := &StepStatus{
Issue: issue,
}
switch issue.Status {
case types.StatusClosed:
step.Status = "done"
progress.Completed++
case types.StatusInProgress:
step.Status = "current"
step.IsCurrent = true
progress.CurrentStep = issue
case types.StatusBlocked:
step.Status = "blocked"
default:
// Check if ready (unblocked)
if readyIDs[issue.ID] {
step.Status = "ready"
if progress.NextStep == nil {
progress.NextStep = issue
}
} else {
step.Status = "pending"
}
}
steps = append(steps, step)
}
// Sort steps by dependency order
sortStepsByDependencyOrder(steps, subgraph)
progress.Steps = steps
// If no current step but there's a ready step, set it as next
if progress.CurrentStep == nil && progress.NextStep == nil {
for _, step := range steps {
if step.Status == "ready" {
progress.NextStep = step.Issue
break
}
}
}
return progress, nil
}
// findInProgressMolecules finds molecules with in_progress steps for an agent
func findInProgressMolecules(ctx context.Context, s storage.Storage, agent string) []*MoleculeProgress {
// Query for in_progress issues
var inProgressIssues []*types.Issue
if daemonClient != nil {
listArgs := &rpc.ListArgs{
Status: "in_progress",
Assignee: agent,
}
resp, err := daemonClient.List(listArgs)
if err == nil {
json.Unmarshal(resp.Data, &inProgressIssues)
}
} else {
// Direct query - search for in_progress issues
status := types.StatusInProgress
filter := types.IssueFilter{Status: &status}
if agent != "" {
filter.Assignee = &agent
}
allIssues, err := s.SearchIssues(ctx, "", filter)
if err == nil {
inProgressIssues = allIssues
}
}
if len(inProgressIssues) == 0 {
return nil
}
// For each in_progress issue, find its parent molecule
moleculeMap := make(map[string]*MoleculeProgress)
for _, issue := range inProgressIssues {
moleculeID := findParentMolecule(ctx, s, issue.ID)
if moleculeID == "" {
// Not part of a molecule, skip
continue
}
if _, exists := moleculeMap[moleculeID]; !exists {
progress, err := getMoleculeProgress(ctx, s, moleculeID)
if err == nil {
moleculeMap[moleculeID] = progress
}
}
}
// Convert to slice
var molecules []*MoleculeProgress
for _, mol := range moleculeMap {
molecules = append(molecules, mol)
}
// Sort by molecule ID for consistent output
sort.Slice(molecules, func(i, j int) bool {
return molecules[i].MoleculeID < molecules[j].MoleculeID
})
return molecules
}
// findParentMolecule walks up parent-child chain to find the root molecule
func findParentMolecule(ctx context.Context, s storage.Storage, issueID string) string {
visited := make(map[string]bool)
currentID := issueID
for !visited[currentID] {
visited[currentID] = true
// Get dependencies for current issue
deps, err := s.GetDependencyRecords(ctx, currentID)
if err != nil {
return ""
}
// Find parent-child dependency where current is the child
var parentID string
for _, dep := range deps {
if dep.Type == types.DepParentChild && dep.IssueID == currentID {
parentID = dep.DependsOnID
break
}
}
if parentID == "" {
// No parent - check if current issue is a molecule root
issue, err := s.GetIssue(ctx, currentID)
if err != nil || issue == nil {
return ""
}
// Check if it has the template label (molecules are spawned from templates)
for _, label := range issue.Labels {
if label == BeadsTemplateLabel {
return currentID
}
}
// Also check if it's an epic with children (ad-hoc molecule)
if issue.IssueType == types.TypeEpic {
return currentID
}
return ""
}
currentID = parentID
}
return ""
}
// sortStepsByDependencyOrder sorts steps by their dependency order
func sortStepsByDependencyOrder(steps []*StepStatus, subgraph *TemplateSubgraph) {
// Build dependency graph
depCount := make(map[string]int) // issue ID -> number of deps
for _, step := range steps {
depCount[step.Issue.ID] = 0
}
// Count blocking dependencies within the step set
stepIDs := make(map[string]bool)
for _, step := range steps {
stepIDs[step.Issue.ID] = true
}
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks && stepIDs[dep.IssueID] && stepIDs[dep.DependsOnID] {
depCount[dep.IssueID]++
}
}
// Stable sort by dependency count (fewer deps first)
sort.SliceStable(steps, func(i, j int) bool {
return depCount[steps[i].Issue.ID] < depCount[steps[j].Issue.ID]
})
}
// printMoleculeProgress prints the progress in human-readable format
func printMoleculeProgress(mol *MoleculeProgress) {
fmt.Printf("You're working on molecule %s\n", ui.RenderAccent(mol.MoleculeID))
fmt.Printf(" %s\n", mol.MoleculeTitle)
if mol.Assignee != "" {
fmt.Printf(" Assigned to: %s\n", mol.Assignee)
}
fmt.Println()
for _, step := range mol.Steps {
statusIcon := getStatusIcon(step.Status)
marker := ""
if step.IsCurrent {
marker = " <- YOU ARE HERE"
}
fmt.Printf(" %s %s: %s%s\n", statusIcon, step.Issue.ID, step.Issue.Title, marker)
}
fmt.Println()
fmt.Printf("Progress: %d/%d steps complete\n", mol.Completed, mol.Total)
if mol.NextStep != nil && mol.CurrentStep == nil {
fmt.Printf("\nNext ready: %s - %s\n", mol.NextStep.ID, mol.NextStep.Title)
fmt.Printf(" Start with: bd update %s --status in_progress\n", mol.NextStep.ID)
}
}
// getStatusIcon returns the icon for a step status
func getStatusIcon(status string) string {
switch status {
case "done":
return ui.RenderPass("[done]")
case "current":
return ui.RenderWarn("[current]")
case "ready":
return ui.RenderAccent("[ready]")
case "blocked":
return ui.RenderFail("[blocked]")
default:
return "[pending]"
}
}
// ContinueResult holds the result of advancing to the next molecule step
type ContinueResult struct {
ClosedStep *types.Issue `json:"closed_step"`
NextStep *types.Issue `json:"next_step,omitempty"`
AutoAdvanced bool `json:"auto_advanced"`
MolComplete bool `json:"molecule_complete"`
MoleculeID string `json:"molecule_id,omitempty"`
}
// AdvanceToNextStep finds the next ready step in a molecule after closing a step
// If autoClaim is true, it marks the next step as in_progress
// Returns nil if the issue is not part of a molecule
func AdvanceToNextStep(ctx context.Context, s storage.Storage, closedStepID string, autoClaim bool, actorName string) (*ContinueResult, error) {
if s == nil {
return nil, fmt.Errorf("no database connection")
}
// Get the closed step
closedStep, err := s.GetIssue(ctx, closedStepID)
if err != nil || closedStep == nil {
return nil, fmt.Errorf("could not get closed step: %w", err)
}
result := &ContinueResult{
ClosedStep: closedStep,
}
// Find parent molecule
moleculeID := findParentMolecule(ctx, s, closedStepID)
if moleculeID == "" {
// Not part of a molecule - nothing to advance
return nil, nil
}
result.MoleculeID = moleculeID
// Load molecule progress
progress, err := getMoleculeProgress(ctx, s, moleculeID)
if err != nil {
return nil, fmt.Errorf("could not load molecule: %w", err)
}
// Check if molecule is complete
if progress.Completed >= progress.Total {
result.MolComplete = true
return result, nil
}
// Find next ready step
var nextStep *types.Issue
for _, step := range progress.Steps {
if step.Status == "ready" {
nextStep = step.Issue
break
}
}
if nextStep == nil {
// No ready steps - might be blocked
return result, nil
}
result.NextStep = nextStep
// Auto-claim if requested
if autoClaim {
updates := map[string]interface{}{
"status": types.StatusInProgress,
}
if err := s.UpdateIssue(ctx, nextStep.ID, updates, actorName); err != nil {
return result, fmt.Errorf("could not claim next step: %w", err)
}
result.AutoAdvanced = true
}
return result, nil
}
// PrintContinueResult prints the result of advancing to the next step
func PrintContinueResult(result *ContinueResult) {
if result == nil {
return
}
if result.MolComplete {
fmt.Printf("\n%s Molecule %s complete! All steps closed.\n", ui.RenderPass("✓"), result.MoleculeID)
fmt.Println("Consider: bd mol squash " + result.MoleculeID + " --summary '...'")
return
}
if result.NextStep == nil {
fmt.Println("\nNo ready steps in molecule (may be blocked).")
return
}
fmt.Printf("\nNext ready in molecule:\n")
fmt.Printf(" %s: %s\n", result.NextStep.ID, result.NextStep.Title)
if result.AutoAdvanced {
fmt.Printf("\n%s Marked in_progress (use --no-auto to skip)\n", ui.RenderWarn("→"))
} else {
fmt.Printf("\nStart with: bd update %s --status in_progress\n", result.NextStep.ID)
}
}
func init() {
molCurrentCmd.Flags().String("for", "", "Show molecules for a specific agent/assignee")
molCmd.AddCommand(molCurrentCmd)
}

View File

@@ -977,9 +977,17 @@ var closeCmd = &cobra.Command{
} }
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
continueFlag, _ := cmd.Flags().GetBool("continue")
noAuto, _ := cmd.Flags().GetBool("no-auto")
ctx := rootCtx ctx := rootCtx
// --continue only works with a single issue
if continueFlag && len(args) > 1 {
fmt.Fprintf(os.Stderr, "Error: --continue only works when closing a single issue\n")
os.Exit(1)
}
// Resolve partial IDs first // Resolve partial IDs first
var resolvedIDs []string var resolvedIDs []string
if daemonClient != nil { if daemonClient != nil {
@@ -1054,6 +1062,13 @@ var closeCmd = &cobra.Command{
} }
} }
// Handle --continue flag in daemon mode (bd-ieyy)
// Note: --continue requires direct database access to walk parent-child chain
if continueFlag && len(closedIssues) > 0 {
fmt.Fprintf(os.Stderr, "\nNote: --continue requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon close %s --continue\n", resolvedIDs[0])
}
if jsonOutput && len(closedIssues) > 0 { if jsonOutput && len(closedIssues) > 0 {
outputJSON(closedIssues) outputJSON(closedIssues)
} }
@@ -1062,6 +1077,7 @@ var closeCmd = &cobra.Command{
// Direct mode // Direct mode
closedIssues := []*types.Issue{} closedIssues := []*types.Issue{}
closedCount := 0
for _, id := range resolvedIDs { for _, id := range resolvedIDs {
// Get issue for checks // Get issue for checks
issue, _ := store.GetIssue(ctx, id) issue, _ := store.GetIssue(ctx, id)
@@ -1085,6 +1101,8 @@ var closeCmd = &cobra.Command{
continue continue
} }
closedCount++
// Run close hook (bd-kwro.8) // Run close hook (bd-kwro.8)
closedIssue, _ := store.GetIssue(ctx, id) closedIssue, _ := store.GetIssue(ctx, id)
if closedIssue != nil && hookRunner != nil { if closedIssue != nil && hookRunner != nil {
@@ -1105,6 +1123,25 @@ var closeCmd = &cobra.Command{
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
// Handle --continue flag (bd-ieyy)
if continueFlag && len(resolvedIDs) == 1 && closedCount > 0 {
autoClaim := !noAuto
result, err := AdvanceToNextStep(ctx, store, resolvedIDs[0], autoClaim, actor)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not advance to next step: %v\n", err)
} else if result != nil {
if jsonOutput {
// Include continue result in JSON output
outputJSON(map[string]interface{}{
"closed": closedIssues,
"continue": result,
})
return
}
PrintContinueResult(result)
}
}
if jsonOutput && len(closedIssues) > 0 { if jsonOutput && len(closedIssues) > 0 {
outputJSON(closedIssues) outputJSON(closedIssues)
} }
@@ -1376,5 +1413,7 @@ func init() {
closeCmd.Flags().StringP("reason", "r", "", "Reason for closing") closeCmd.Flags().StringP("reason", "r", "", "Reason for closing")
closeCmd.Flags().Bool("json", false, "Output JSON format") closeCmd.Flags().Bool("json", false, "Output JSON format")
closeCmd.Flags().BoolP("force", "f", false, "Force close pinned issues") closeCmd.Flags().BoolP("force", "f", false, "Force close pinned issues")
closeCmd.Flags().Bool("continue", false, "Auto-advance to next step in molecule")
closeCmd.Flags().Bool("no-auto", false, "With --continue, show next step but don't claim it")
rootCmd.AddCommand(closeCmd) rootCmd.AddCommand(closeCmd)
} }