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

@@ -121,7 +121,7 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er
FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
),
-- Step 2: Propagate blockage to all descendants via parent-child

View File

@@ -78,7 +78,7 @@ func (s *SQLiteStorage) GetTier1Candidates(ctx context.Context) ([]*CompactionCa
COUNT(DISTINCT dt.dependent_id) as dependent_count
FROM issues i
LEFT JOIN dependent_tree dt ON i.id = dt.issue_id
AND dt.dependent_status IN ('open', 'in_progress', 'blocked')
AND dt.dependent_status IN ('open', 'in_progress', 'blocked', 'deferred')
AND dt.depth <= ?
WHERE i.status = 'closed'
AND i.closed_at IS NOT NULL
@@ -163,7 +163,7 @@ func (s *SQLiteStorage) GetTier2Candidates(ctx context.Context) ([]*CompactionCa
JOIN issues dep ON d.issue_id = dep.id
WHERE d.depends_on_id = i.id
AND d.type = 'blocks'
AND dep.status IN ('open', 'in_progress', 'blocked')
AND dep.status IN ('open', 'in_progress', 'blocked', 'deferred')
)
ORDER BY i.closed_at ASC
`

View File

@@ -112,16 +112,18 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
// Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately)
// (bd-6v2: also count pinned issues)
// (bd-4jr: also count deferred issues)
err := s.db.QueryRowContext(ctx, `
SELECT
COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total,
COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) as open,
COALESCE(SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress,
COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed,
COALESCE(SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END), 0) as deferred,
COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone,
COALESCE(SUM(CASE WHEN status = 'pinned' THEN 1 ELSE 0 END), 0) as pinned
FROM issues
`).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.TombstoneIssues, &stats.PinnedIssues)
`).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.DeferredIssues, &stats.TombstoneIssues, &stats.PinnedIssues)
if err != nil {
return nil, fmt.Errorf("failed to get issue counts: %w", err)
}
@@ -132,9 +134,9 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE i.status IN ('open', 'in_progress', 'blocked')
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
AND d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
`).Scan(&stats.BlockedIssues)
if err != nil {
return nil, fmt.Errorf("failed to get blocked count: %w", err)
@@ -147,10 +149,10 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
WHERE i.status = 'open'
AND NOT EXISTS (
SELECT 1 FROM dependencies d
JOIN issues blocked ON d.depends_on_id = blocked.id
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.issue_id = i.id
AND d.type = 'blocks'
AND blocked.status IN ('open', 'in_progress', 'blocked')
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
)
`).Scan(&stats.ReadyIssues)
if err != nil {

View File

@@ -293,18 +293,19 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
AND EXISTS (
SELECT 1 FROM issues blocker
WHERE blocker.id = d.depends_on_id
AND blocker.status IN ('open', 'in_progress', 'blocked')
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
)
WHERE i.status IN ('open', 'in_progress', 'blocked')
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
AND i.pinned = 0
AND (
i.status = 'blocked'
OR i.status = 'deferred'
OR EXISTS (
SELECT 1 FROM dependencies d2
JOIN issues blocker ON d2.depends_on_id = blocker.id
WHERE d2.issue_id = i.id
AND d2.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
)
)
GROUP BY i.id

View File

@@ -206,7 +206,7 @@ WITH RECURSIVE
FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
),
-- Propagate blockage to all descendants via parent-child
blocked_transitively AS (
@@ -236,8 +236,8 @@ SELECT
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE i.status IN ('open', 'in_progress', 'blocked')
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
AND d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
GROUP BY i.id;
`