feat(status): add deferred status for icebox issues (bd-4jr)

Add 'deferred' as a valid issue status for issues that are deliberately
put on ice - not blocked by dependencies, just postponed for later.

Changes:
- Add StatusDeferred constant and update IsValid() validation
- Add DeferredIssues to Statistics struct with counting in both SQLite
  and memory storage
- Add 'bd defer' command to set status to deferred
- Add 'bd undefer' command to restore status to open
- Update help text across list, search, count, dep, stale, and config
- Update MCP server models and tools to accept deferred status
- Add deferred to blocker status checks (schema, cache, ready, compact)
- Add StatusDeferred to public API exports (beads.go, internal/beads)
- Add snowflake styling for deferred in dep tree and graph views

Semantics:
- deferred vs blocked: deferred is a choice, blocked is forced
- deferred vs closed: deferred will be revisited, closed is done
- Deferred issues excluded from 'bd ready' (already works since
  default filter only includes open/in_progress)
- Deferred issues still block dependents (they are not done!)
- Deferred issues visible in 'bd list' and 'bd stale'

🤖 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-20 14:13:57 -08:00
parent 844e9ffc02
commit e778b3f648
22 changed files with 335 additions and 33 deletions

View File

@@ -32,7 +32,7 @@ Custom Status States:
bd config set status.custom "awaiting_review,awaiting_testing,awaiting_docs"
This enables issues to use statuses like 'awaiting_review' in addition to
the built-in statuses (open, in_progress, blocked, closed).
the built-in statuses (open, in_progress, blocked, deferred, closed).
Examples:
bd config set jira.url "https://company.atlassian.net"

View File

@@ -420,7 +420,7 @@ Examples:
func init() {
// Filter flags (same as list command)
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
countCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
countCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")

142
cmd/bd/defer.go Normal file
View File

@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
var deferCmd = &cobra.Command{
Use: "defer [id...]",
Short: "Defer one or more issues for later",
Long: `Defer issues to put them on ice for later.
Deferred issues are deliberately set aside - not blocked by anything specific,
just postponed for future consideration. Unlike blocked issues, there's no
dependency keeping them from being worked. Unlike closed issues, they will
be revisited.
Deferred issues don't show in 'bd ready' but remain visible in 'bd list'.
Examples:
bd defer bd-abc # Defer a single issue
bd defer bd-abc bd-def # Defer multiple issues`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("defer")
ctx := rootCtx
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, resolvedID)
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
deferredIssues := []*types.Issue{}
// If daemon is running, use RPC
if daemonClient != nil {
for _, id := range resolvedIDs {
status := string(types.StatusDeferred)
updateArgs := &rpc.UpdateArgs{
ID: id,
Status: &status,
}
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", id, err)
continue
}
if jsonOutput {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
deferredIssues = append(deferredIssues, &issue)
}
} else {
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("%s Deferred %s\n", cyan("*"), id)
}
}
if jsonOutput && len(deferredIssues) > 0 {
outputJSON(deferredIssues)
}
return
}
// Fall back to direct storage access
if store == nil {
fmt.Fprintln(os.Stderr, "Error: database not initialized")
os.Exit(1)
}
for _, id := range args {
fullID, err := utils.ResolvePartialID(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
updates := map[string]interface{}{
"status": string(types.StatusDeferred),
}
if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", fullID, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, fullID)
if issue != nil {
deferredIssues = append(deferredIssues, issue)
}
} else {
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("%s Deferred %s\n", cyan("*"), fullID)
}
}
// Schedule auto-flush if any issues were deferred
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(deferredIssues) > 0 {
outputJSON(deferredIssues)
}
},
}
func init() {
rootCmd.AddCommand(deferCmd)
}

View File

@@ -497,6 +497,8 @@ func getStatusEmoji(status types.Status) string {
return "◧" // U+25E7 Square Left Half Black
case types.StatusBlocked:
return "⚠" // U+26A0 Warning Sign
case types.StatusDeferred:
return "❄" // U+2744 Snowflake (on ice)
case types.StatusClosed:
return "☑" // U+2611 Ballot Box with Check
default:
@@ -746,7 +748,7 @@ func init() {
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (deprecated: use --direction=up)")
depTreeCmd.Flags().String("direction", "", "Tree direction: 'down' (dependencies), 'up' (dependents), or 'both'")
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, closed)")
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")
// Note: --json flag is defined as a persistent flag in main.go, not here

View File

@@ -382,6 +382,9 @@ func renderNodeBox(node *GraphNode, width int) string {
case types.StatusBlocked:
statusIcon = "●"
colorFn = color.New(color.FgRed).SprintFunc()
case types.StatusDeferred:
statusIcon = "❄"
colorFn = color.New(color.FgCyan).SprintFunc()
case types.StatusClosed:
statusIcon = "✓"
colorFn = color.New(color.FgGreen).SprintFunc()
@@ -458,6 +461,9 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB
case types.StatusBlocked:
statusIcon = "●"
colorFn = color.New(color.FgRed).SprintFunc()
case types.StatusDeferred:
statusIcon = "❄"
colorFn = color.New(color.FgCyan).SprintFunc()
case types.StatusClosed:
statusIcon = "✓"
colorFn = color.New(color.FgGreen).SprintFunc()

View File

@@ -565,7 +565,7 @@ var listCmd = &cobra.Command{
}
func init() {
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
registerPriorityFlag(listCmd, "")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)")

View File

@@ -370,7 +370,7 @@ func outputSearchResults(issues []*types.Issue, query string, longFormat bool) {
func init() {
searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)")
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")

View File

@@ -23,8 +23,8 @@ This helps identify:
limit, _ := cmd.Flags().GetInt("limit")
// Use global jsonOutput set by PersistentPreRun
// Validate status if provided
if status != "" && status != "open" && status != "in_progress" && status != "blocked" {
fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked\n", status)
if status != "" && status != "open" && status != "in_progress" && status != "blocked" && status != "deferred" {
fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked, deferred\n", status)
os.Exit(1)
}
filter := types.StaleFilter{
@@ -107,7 +107,7 @@ func displayStaleIssues(issues []*types.Issue, days int) {
}
func init() {
staleCmd.Flags().IntP("days", "d", 30, "Issues not updated in this many days")
staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked)")
staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked|deferred)")
staleCmd.Flags().IntP("limit", "n", 50, "Maximum issues to show")
staleCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format")
rootCmd.AddCommand(staleCmd)

View File

@@ -312,6 +312,8 @@ func getAssignedStatistics(assignee string) *types.Statistics {
stats.InProgressIssues++
case types.StatusBlocked:
stats.BlockedIssues++
case types.StatusDeferred:
stats.DeferredIssues++
case types.StatusClosed:
stats.ClosedIssues++
}

138
cmd/bd/undefer.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
var undeferCmd = &cobra.Command{
Use: "undefer [id...]",
Short: "Undefer one or more issues (restore to open)",
Long: `Undefer issues to restore them to open status.
This brings issues back from the icebox so they can be worked on again.
Issues will appear in 'bd ready' if they have no blockers.
Examples:
bd undefer bd-abc # Undefer a single issue
bd undefer bd-abc bd-def # Undefer multiple issues`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("undefer")
ctx := rootCtx
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, resolvedID)
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
undeferredIssues := []*types.Issue{}
// If daemon is running, use RPC
if daemonClient != nil {
for _, id := range resolvedIDs {
status := string(types.StatusOpen)
updateArgs := &rpc.UpdateArgs{
ID: id,
Status: &status,
}
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", id, err)
continue
}
if jsonOutput {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
undeferredIssues = append(undeferredIssues, &issue)
}
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Undeferred %s (now open)\n", green("*"), id)
}
}
if jsonOutput && len(undeferredIssues) > 0 {
outputJSON(undeferredIssues)
}
return
}
// Fall back to direct storage access
if store == nil {
fmt.Fprintln(os.Stderr, "Error: database not initialized")
os.Exit(1)
}
for _, id := range args {
fullID, err := utils.ResolvePartialID(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
updates := map[string]interface{}{
"status": string(types.StatusOpen),
}
if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", fullID, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, fullID)
if issue != nil {
undeferredIssues = append(undeferredIssues, issue)
}
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Undeferred %s (now open)\n", green("*"), fullID)
}
}
// Schedule auto-flush if any issues were undeferred
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(undeferredIssues) > 0 {
outputJSON(undeferredIssues)
}
},
}
func init() {
rootCmd.AddCommand(undeferCmd)
}