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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user