feat(close): Add --suggest-next flag to show newly unblocked issues (GH#679)

When closing an issue, the new --suggest-next flag returns a list of
issues that became unblocked (ready to work on) as a result of the close.

This helps agents and users quickly identify what work is now available
after completing a blocker.

Example:
  $ bd close bd-5 --suggest-next
  ✓ Closed bd-5: Completed

  Newly unblocked:
    • bd-7 "Implement feature X" (P1)
    • bd-8 "Write tests for X" (P2)

Implementation:
- Added GetNewlyUnblockedByClose to storage interface
- Implemented efficient single-query for SQLite using blocked_issues_cache
- Added SuggestNext field to CloseArgs in RPC protocol
- Added CloseResult type for structured response
- CLI handles both daemon and direct modes

Thanks to @kraitsura for the detailed feature request and design.

🤖 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-25 20:05:04 -08:00
parent 35ab0d7a7f
commit f3a5e02a35
16 changed files with 306 additions and 140 deletions

View File

@@ -18,8 +18,8 @@ var formulaCmd = &cobra.Command{
Short: "Manage workflow formulas",
Long: `Manage workflow formulas - the source layer for molecule templates.
Formulas are JSON files (.formula.json) that define workflows with composition rules.
They are "cooked" into ephemeral protos which can then be poured or wisped.
Formulas are YAML/JSON files that define workflows with composition rules.
They are "cooked" into proto beads which can then be poured or wisped.
The Rig → Cook → Run lifecycle:
- Rig: Compose formulas (extends, compose)

View File

@@ -15,22 +15,21 @@ import (
)
var molDistillCmd = &cobra.Command{
Use: "distill <id> [formula-name]",
Short: "Extract a formula from a mol, wisp, or epic",
Long: `Extract a reusable formula from completed work.
Use: "distill <epic-id> [formula-name]",
Short: "Extract a formula from an existing epic",
Long: `Distill a molecule by extracting a reusable formula from an existing epic.
This is the reverse of pour: instead of formula → mol, it's mol → formula.
Works with any hierarchical work: mols, wisps, or plain epics.
This is the reverse of pour: instead of formula → molecule, it's molecule → formula.
The distill command:
1. Loads the work item and all its children
1. Loads the existing epic and all its children
2. Converts the structure to a .formula.json file
3. Replaces concrete values with {{variable}} placeholders (via --var flags)
Use cases:
- Emergent patterns: structured work manually, want to templatize
- Modified execution: poured formula, added steps, want to capture
- Learning from success: extract what made a workflow succeed
- Team develops good workflow organically, wants to reuse it
- Capture tribal knowledge as executable templates
- Create starting point for similar future work
Variable syntax (both work - we detect which side is the concrete value):
--var branch=feature-auth Spawn-style: variable=value (recommended)
@@ -41,10 +40,8 @@ Output locations (first writable wins):
2. ~/.beads/formulas/ (user-level, if project not writable)
Examples:
bd mol distill bd-mol-xyz my-workflow
bd mol distill bd-wisp-abc patrol-template
bd mol distill bd-epic-123 release-workflow --var version=1.2.3
bd mol distill bd-xyz workflow -o ./formulas/`,
bd mol distill bd-o5xe my-workflow
bd mol distill bd-abc release-workflow --var feature_name=auth-refactor`,
Args: cobra.RangeArgs(1, 2),
Run: runMolDistill,
}
@@ -105,9 +102,14 @@ func parseDistillVar(varFlag, searchableText string) (string, string, error) {
func runMolDistill(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Check we have some database access
if store == nil && daemonClient == nil {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
// mol distill requires direct store access for reading the epic
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: mol distill requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol distill %s ...\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
@@ -115,23 +117,17 @@ func runMolDistill(cmd *cobra.Command, args []string) {
dryRun, _ := cmd.Flags().GetBool("dry-run")
outputDir, _ := cmd.Flags().GetString("output")
// Load the subgraph (works with daemon or direct)
// Show/GetIssue handle partial ID resolution
var subgraph *TemplateSubgraph
var err error
if daemonClient != nil {
subgraph, err = loadTemplateSubgraphViaDaemon(daemonClient, args[0])
} else {
// Resolve ID for direct access
issueID, resolveErr := utils.ResolvePartialID(ctx, store, args[0])
if resolveErr != nil {
fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[0])
os.Exit(1)
}
subgraph, err = loadTemplateSubgraph(ctx, store, issueID)
}
// Resolve epic ID
epicID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading issue: %v\n", err)
fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[0])
os.Exit(1)
}
// Load the epic subgraph
subgraph, err := loadTemplateSubgraph(ctx, store, epicID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err)
os.Exit(1)
}
@@ -176,7 +172,7 @@ func runMolDistill(cmd *cobra.Command, args []string) {
}
if dryRun {
fmt.Printf("\nDry run: would distill %d steps from %s into formula\n\n", countSteps(f.Steps), subgraph.Root.ID)
fmt.Printf("\nDry run: would distill %d steps from %s into formula\n\n", countSteps(f.Steps), epicID)
fmt.Printf("Formula: %s\n", formulaName)
fmt.Printf("Output: %s\n", outputPath)
if len(replacements) > 0 {
@@ -369,7 +365,7 @@ func subgraphToFormula(subgraph *TemplateSubgraph, name string, replacements map
func init() {
molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (variable=value)")
molDistillCmd.Flags().Bool("dry-run", false, "Preview what would be created")
molDistillCmd.Flags().StringP("output", "o", "", "Output directory for formula file")
molDistillCmd.Flags().String("output", "", "Output directory for formula file")
molCmd.AddCommand(molDistillCmd)
}

View File

@@ -952,6 +952,7 @@ var closeCmd = &cobra.Command{
force, _ := cmd.Flags().GetBool("force")
continueFlag, _ := cmd.Flags().GetBool("continue")
noAuto, _ := cmd.Flags().GetBool("no-auto")
suggestNext, _ := cmd.Flags().GetBool("suggest-next")
ctx := rootCtx
@@ -960,6 +961,11 @@ var closeCmd = &cobra.Command{
FatalErrorRespectJSON("--continue only works when closing a single issue")
}
// --suggest-next only works with a single issue
if suggestNext && len(args) > 1 {
FatalErrorRespectJSON("--suggest-next only works when closing a single issue")
}
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
@@ -1007,8 +1013,9 @@ var closeCmd = &cobra.Command{
}
closeArgs := &rpc.CloseArgs{
ID: id,
Reason: reason,
ID: id,
Reason: reason,
SuggestNext: suggestNext,
}
resp, err := daemonClient.CloseIssue(closeArgs)
if err != nil {
@@ -1016,18 +1023,44 @@ var closeCmd = &cobra.Command{
continue
}
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
// Run close hook (bd-kwro.8)
if hookRunner != nil {
hookRunner.Run(hooks.EventClose, &issue)
// Handle response based on whether SuggestNext was requested (GH#679)
if suggestNext {
var result rpc.CloseResult
if err := json.Unmarshal(resp.Data, &result); err == nil {
if result.Closed != nil {
// Run close hook (bd-kwro.8)
if hookRunner != nil {
hookRunner.Run(hooks.EventClose, result.Closed)
}
if jsonOutput {
closedIssues = append(closedIssues, result.Closed)
}
}
if !jsonOutput {
fmt.Printf("%s Closed %s: %s\n", ui.RenderPass("✓"), id, reason)
// Display newly unblocked issues (GH#679)
if len(result.Unblocked) > 0 {
fmt.Printf("\nNewly unblocked:\n")
for _, issue := range result.Unblocked {
fmt.Printf(" • %s %q (P%d)\n", issue.ID, issue.Title, issue.Priority)
}
}
}
}
if jsonOutput {
closedIssues = append(closedIssues, &issue)
} else {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
// Run close hook (bd-kwro.8)
if hookRunner != nil {
hookRunner.Run(hooks.EventClose, &issue)
}
if jsonOutput {
closedIssues = append(closedIssues, &issue)
}
}
if !jsonOutput {
fmt.Printf("%s Closed %s: %s\n", ui.RenderPass("✓"), id, reason)
}
}
if !jsonOutput {
fmt.Printf("%s Closed %s: %s\n", ui.RenderPass("✓"), id, reason)
}
}
@@ -1087,6 +1120,24 @@ var closeCmd = &cobra.Command{
}
}
// Handle --suggest-next flag in direct mode (GH#679)
if suggestNext && len(resolvedIDs) == 1 && closedCount > 0 {
unblocked, err := store.GetNewlyUnblockedByClose(ctx, resolvedIDs[0])
if err == nil && len(unblocked) > 0 {
if jsonOutput {
outputJSON(map[string]interface{}{
"closed": closedIssues,
"unblocked": unblocked,
})
return
}
fmt.Printf("\nNewly unblocked:\n")
for _, issue := range unblocked {
fmt.Printf(" • %s %q (P%d)\n", issue.ID, issue.Title, issue.Priority)
}
}
}
// Schedule auto-flush if any issues were closed
if len(args) > 0 {
markDirtyAndScheduleFlush()
@@ -1380,5 +1431,6 @@ func init() {
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")
closeCmd.Flags().Bool("suggest-next", false, "Show newly unblocked issues after closing (GH#679)")
rootCmd.AddCommand(closeCmd)
}