From 44626262e2c0b996cc11e27af926509b877116c6 Mon Sep 17 00:00:00 2001 From: kraitsura Date: Sat, 3 Jan 2026 23:18:30 -0800 Subject: [PATCH 1/2] feat(dep): add --blocks shorthand flag for natural dependency syntax Agents naturally try to use 'bd dep --blocks ' when establishing blocking relationships - a desire path revealing intuitive mental model for how dependencies should work. When AI agents set up dependency chains, they consistently attempt: bd dep conduit-abc --blocks conduit-xyz This reveals a desire path - the syntax users naturally reach for before reading documentation. Instead of fighting this intuition, we embrace it. - Add --blocks (-b) flag to the bd dep command - Support syntax: bd dep --blocks - Equivalent to: bd dep add - Full daemon and direct mode support - Cycle detection and child-parent anti-pattern checks - JSON output support for programmatic use This is purely additive. The existing command structure remains: - 'bd dep add' subcommand works exactly as before - All other dep subcommands (remove, list, tree, cycles) unchanged - No breaking changes to existing workflows bd dep bd-xyz --blocks bd-abc # bd-xyz blocks bd-abc bd dep bd-xyz -b bd-abc # Same, using shorthand bd dep add bd-abc bd-xyz # Original syntax still works - Added TestDepBlocksFlag for flag initialization - Added TestDepBlocksFlagFunctionality for semantic correctness - All existing tests pass --- cmd/bd/dep.go | 162 +++++++++++++++++++++++++++++++++++++++++++-- cmd/bd/dep_test.go | 101 +++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 7 deletions(-) diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 1e06904e..00e1cc44 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -43,9 +43,162 @@ func isChildOf(childID, parentID string) bool { } var depCmd = &cobra.Command{ - Use: "dep", + Use: "dep [issue-id]", GroupID: "deps", Short: "Manage dependencies", + Long: `Manage dependencies between issues. + +When called with an issue ID and --blocks flag, creates a blocking dependency: + bd dep --blocks + +This is equivalent to: + bd dep add + +Examples: + bd dep bd-xyz --blocks bd-abc # bd-xyz blocks bd-abc + bd dep add bd-abc bd-xyz # Same as above (bd-abc depends on bd-xyz)`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + blocksID, _ := cmd.Flags().GetString("blocks") + + // If no args and no flags, show help + if len(args) == 0 && blocksID == "" { + _ = cmd.Help() + return + } + + // If --blocks flag is provided, create a blocking dependency + if blocksID != "" { + if len(args) != 1 { + FatalErrorRespectJSON("--blocks requires exactly one issue ID argument") + } + blockerID := args[0] + + CheckReadonly("dep --blocks") + + ctx := rootCtx + depType := "blocks" + + // Resolve partial IDs first + var fromID, toID string + + if daemonClient != nil { + // Resolve the blocked issue ID (the one that will depend on the blocker) + resolveArgs := &rpc.ResolveIDArgs{ID: blocksID} + resp, err := daemonClient.ResolveID(resolveArgs) + if err != nil { + FatalErrorRespectJSON("resolving issue ID %s: %v", blocksID, err) + } + if err := json.Unmarshal(resp.Data, &fromID); err != nil { + FatalErrorRespectJSON("unmarshaling resolved ID: %v", err) + } + + // Resolve the blocker issue ID + resolveArgs = &rpc.ResolveIDArgs{ID: blockerID} + resp, err = daemonClient.ResolveID(resolveArgs) + if err != nil { + FatalErrorRespectJSON("resolving issue ID %s: %v", blockerID, err) + } + if err := json.Unmarshal(resp.Data, &toID); err != nil { + FatalErrorRespectJSON("unmarshaling resolved ID: %v", err) + } + } else { + var err error + fromID, err = utils.ResolvePartialID(ctx, store, blocksID) + if err != nil { + FatalErrorRespectJSON("resolving issue ID %s: %v", blocksID, err) + } + + toID, err = utils.ResolvePartialID(ctx, store, blockerID) + if err != nil { + FatalErrorRespectJSON("resolving issue ID %s: %v", blockerID, err) + } + } + + // Check for child→parent dependency anti-pattern + if isChildOf(fromID, toID) { + FatalErrorRespectJSON("cannot add dependency: %s is already a child of %s. Children inherit dependency on parent completion via hierarchy. Adding an explicit dependency would create a deadlock", fromID, toID) + } + + // If daemon is running, use RPC + if daemonClient != nil { + depArgs := &rpc.DepAddArgs{ + FromID: fromID, + ToID: toID, + DepType: depType, + } + + resp, err := daemonClient.AddDependency(depArgs) + if err != nil { + FatalErrorRespectJSON("%v", err) + } + + if jsonOutput { + fmt.Println(string(resp.Data)) + return + } + + fmt.Printf("%s Added dependency: %s blocks %s\n", + ui.RenderPass("✓"), blockerID, blocksID) + return + } + + // Direct mode + dep := &types.Dependency{ + IssueID: fromID, + DependsOnID: toID, + Type: types.DependencyType(depType), + } + + if err := store.AddDependency(ctx, dep, actor); err != nil { + FatalErrorRespectJSON("%v", err) + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + // Check for cycles after adding dependency + cycles, err := store.DetectCycles(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err) + } else if len(cycles) > 0 { + fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", ui.RenderWarn("⚠")) + fmt.Fprintf(os.Stderr, "This can hide issues from the ready work list and cause confusion.\n\n") + fmt.Fprintf(os.Stderr, "Cycle path:\n") + for _, cycle := range cycles { + for j, issue := range cycle { + if j == 0 { + fmt.Fprintf(os.Stderr, " %s", issue.ID) + } else { + fmt.Fprintf(os.Stderr, " → %s", issue.ID) + } + } + if len(cycle) > 0 { + fmt.Fprintf(os.Stderr, " → %s", cycle[0].ID) + } + fmt.Fprintf(os.Stderr, "\n") + } + fmt.Fprintf(os.Stderr, "\nRun 'bd dep cycles' for detailed analysis.\n\n") + } + + if jsonOutput { + outputJSON(map[string]interface{}{ + "status": "added", + "blocker_id": toID, + "blocked_id": fromID, + "type": depType, + }) + return + } + + fmt.Printf("%s Added dependency: %s blocks %s\n", + ui.RenderPass("✓"), toID, fromID) + return + } + + // If we have an arg but no --blocks flag, show help + _ = cmd.Help() + }, } var depAddCmd = &cobra.Command{ @@ -1017,10 +1170,12 @@ func ParseExternalRef(ref string) (project, capability string) { } func init() { + // dep command shorthand flag + depCmd.Flags().StringP("blocks", "b", "", "Issue ID that this issue blocks (shorthand for: bd dep add )") + depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|tracks|related|parent-child|discovered-from|until|caused-by|validates|relates-to|supersedes)") depAddCmd.Flags().String("blocked-by", "", "Issue ID that blocks the first issue (alternative to positional arg)") depAddCmd.Flags().String("depends-on", "", "Issue ID that the first issue depends on (alias for --blocked-by)") - // Note: --json flag is defined as a persistent flag in main.go, not here depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)") depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)") @@ -1029,9 +1184,6 @@ func init() { depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, deferred, closed)") depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart") depTreeCmd.Flags().StringP("type", "t", "", "Filter to only show dependencies of this type (e.g., tracks, blocks, parent-child)") - // Note: --json flag is defined as a persistent flag in main.go, not here - - // Note: --json flag is defined as a persistent flag in main.go, not here depListCmd.Flags().String("direction", "down", "Direction: 'down' (dependencies), 'up' (dependents)") depListCmd.Flags().StringP("type", "t", "", "Filter by dependency type (e.g., tracks, blocks, parent-child)") diff --git a/cmd/bd/dep_test.go b/cmd/bd/dep_test.go index 9bcbe354..8b9b4798 100644 --- a/cmd/bd/dep_test.go +++ b/cmd/bd/dep_test.go @@ -237,8 +237,8 @@ func TestDepCommandsInit(t *testing.T) { t.Fatal("depCmd should be initialized") } - if depCmd.Use != "dep" { - t.Errorf("Expected Use='dep', got %q", depCmd.Use) + if depCmd.Use != "dep [issue-id]" { + t.Errorf("Expected Use='dep [issue-id]', got %q", depCmd.Use) } if depAddCmd == nil { @@ -279,6 +279,103 @@ func TestDepAddFlagAliases(t *testing.T) { } } +func TestDepBlocksFlag(t *testing.T) { + // Test that the --blocks flag exists on depCmd + flag := depCmd.Flags().Lookup("blocks") + if flag == nil { + t.Fatal("depCmd should have --blocks flag") + } + + // Test shorthand is -b + if flag.Shorthand != "b" { + t.Errorf("Expected shorthand='b', got %q", flag.Shorthand) + } + + // Test default value is empty string + if flag.DefValue != "" { + t.Errorf("Expected default blocks='', got %q", flag.DefValue) + } + + // Test usage text + if !strings.Contains(flag.Usage, "blocks") { + t.Errorf("Expected flag usage to mention 'blocks', got %q", flag.Usage) + } +} + +func TestDepBlocksFlagFunctionality(t *testing.T) { + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + ctx := context.Background() + + // Create test issues + issues := []*types.Issue{ + { + ID: "test-blocks-1", + Title: "Blocker Issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + }, + { + ID: "test-blocks-2", + Title: "Blocked Issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + }, + } + + for _, issue := range issues { + if err := s.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatal(err) + } + } + + // Add dependency using the same logic as --blocks flag would: + // "blocker --blocks blocked" means blocked depends on blocker + dep := &types.Dependency{ + IssueID: "test-blocks-2", // blocked issue + DependsOnID: "test-blocks-1", // blocker issue + Type: types.DepBlocks, + CreatedAt: time.Now(), + } + + if err := s.AddDependency(ctx, dep, "test"); err != nil { + t.Fatalf("AddDependency failed: %v", err) + } + + // Verify the blocked issue now depends on the blocker + deps, err := s.GetDependencies(ctx, "test-blocks-2") + if err != nil { + t.Fatalf("GetDependencies failed: %v", err) + } + + if len(deps) != 1 { + t.Fatalf("Expected 1 dependency, got %d", len(deps)) + } + + if deps[0].ID != "test-blocks-1" { + t.Errorf("Expected blocked issue to depend on test-blocks-1, got %s", deps[0].ID) + } + + // Verify the blocker has a dependent + dependents, err := s.GetDependents(ctx, "test-blocks-1") + if err != nil { + t.Fatalf("GetDependents failed: %v", err) + } + + if len(dependents) != 1 { + t.Fatalf("Expected 1 dependent, got %d", len(dependents)) + } + + if dependents[0].ID != "test-blocks-2" { + t.Errorf("Expected test-blocks-1 to have dependent test-blocks-2, got %s", dependents[0].ID) + } +} + func TestDepTreeFormatFlag(t *testing.T) { // Test that the --format flag exists on depTreeCmd flag := depTreeCmd.Flags().Lookup("format") From 426f67db41b299fdea76e0e3a81d51ccd5db75d9 Mon Sep 17 00:00:00 2001 From: beads/crew/emma Date: Sun, 4 Jan 2026 11:15:32 -0800 Subject: [PATCH 2/2] fix(dep): ensure cycle detection runs in daemon mode for --blocks flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon code path was returning early after adding the dependency, skipping the cycle detection that runs for direct mode. Restructure so both paths share the cycle detection and output code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/dep.go | 47 +++++++++++++++++++---------------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 00e1cc44..959ed2ba 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -120,7 +120,7 @@ Examples: FatalErrorRespectJSON("cannot add dependency: %s is already a child of %s. Children inherit dependency on parent completion via hierarchy. Adding an explicit dependency would create a deadlock", fromID, toID) } - // If daemon is running, use RPC + // Add the dependency via daemon or direct mode if daemonClient != nil { depArgs := &rpc.DepAddArgs{ FromID: fromID, @@ -128,36 +128,27 @@ Examples: DepType: depType, } - resp, err := daemonClient.AddDependency(depArgs) + _, err := daemonClient.AddDependency(depArgs) if err != nil { FatalErrorRespectJSON("%v", err) } - - if jsonOutput { - fmt.Println(string(resp.Data)) - return + } else { + // Direct mode + dep := &types.Dependency{ + IssueID: fromID, + DependsOnID: toID, + Type: types.DependencyType(depType), } - fmt.Printf("%s Added dependency: %s blocks %s\n", - ui.RenderPass("✓"), blockerID, blocksID) - return + if err := store.AddDependency(ctx, dep, actor); err != nil { + FatalErrorRespectJSON("%v", err) + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() } - // Direct mode - dep := &types.Dependency{ - IssueID: fromID, - DependsOnID: toID, - Type: types.DependencyType(depType), - } - - if err := store.AddDependency(ctx, dep, actor); err != nil { - FatalErrorRespectJSON("%v", err) - } - - // Schedule auto-flush - markDirtyAndScheduleFlush() - - // Check for cycles after adding dependency + // Check for cycles after adding dependency (both daemon and direct mode) cycles, err := store.DetectCycles(ctx) if err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err) @@ -183,10 +174,10 @@ Examples: if jsonOutput { outputJSON(map[string]interface{}{ - "status": "added", - "blocker_id": toID, - "blocked_id": fromID, - "type": depType, + "status": "added", + "blocker_id": toID, + "blocked_id": fromID, + "type": depType, }) return }