From f4f51da007a982699fe2d363324d64a7075047ca Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 21:55:27 -0800 Subject: [PATCH] feat(types): add StatusPinned for persistent beads (bd-6v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pinned status for beads that should stay open indefinitely: - Add StatusPinned constant and update IsValid() - Add PinnedIssues count to Statistics struct - Protect pinned issues from bd close (requires --force) - Show pinned count in bd stats output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/ready.go | 6 ++++++ cmd/bd/show.go | 26 ++++++++++++++++++++++++++ internal/storage/sqlite/events.go | 6 ++++-- internal/types/types.go | 4 +++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index 28427ed6..7044d560 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -272,6 +272,9 @@ var statsCmd = &cobra.Command{ if stats.TombstoneIssues > 0 { fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) } + if stats.PinnedIssues > 0 { + fmt.Printf("Pinned: %d\n", stats.PinnedIssues) + } if stats.AverageLeadTime > 0 { fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime) } @@ -313,6 +316,9 @@ var statsCmd = &cobra.Command{ if stats.TombstoneIssues > 0 { fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) } + if stats.PinnedIssues > 0 { + fmt.Printf("Pinned: %d\n", stats.PinnedIssues) + } if stats.EpicsEligibleForClosure > 0 { fmt.Printf("Epics Ready to Close: %s\n", green(fmt.Sprintf("%d", stats.EpicsEligibleForClosure))) } diff --git a/cmd/bd/show.go b/cmd/bd/show.go index eaabb514..0f7ec22b 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -968,6 +968,7 @@ var closeCmd = &cobra.Command{ reason = "Closed" } jsonOutput, _ := cmd.Flags().GetBool("json") + force, _ := cmd.Flags().GetBool("force") ctx := rootCtx @@ -1001,6 +1002,21 @@ var closeCmd = &cobra.Command{ if daemonClient != nil { closedIssues := []*types.Issue{} for _, id := range resolvedIDs { + // Check if issue is pinned (bd-6v2) + if !force { + showArgs := &rpc.ShowArgs{ID: id} + showResp, showErr := daemonClient.Show(showArgs) + if showErr == nil { + var issue types.Issue + if json.Unmarshal(showResp.Data, &issue) == nil { + if issue.Status == types.StatusPinned { + fmt.Fprintf(os.Stderr, "Error: cannot close pinned issue %s (use --force to override)\n", id) + continue + } + } + } + } + closeArgs := &rpc.CloseArgs{ ID: id, Reason: reason, @@ -1036,6 +1052,15 @@ var closeCmd = &cobra.Command{ // Direct mode closedIssues := []*types.Issue{} for _, id := range resolvedIDs { + // Check if issue is pinned (bd-6v2) + if !force { + issue, _ := store.GetIssue(ctx, id) + if issue != nil && issue.Status == types.StatusPinned { + fmt.Fprintf(os.Stderr, "Error: cannot close pinned issue %s (use --force to override)\n", id) + continue + } + } + if err := store.CloseIssue(ctx, id, reason, actor); err != nil { fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) continue @@ -1335,5 +1360,6 @@ func init() { closeCmd.Flags().StringP("reason", "r", "", "Reason for closing") closeCmd.Flags().Bool("json", false, "Output JSON format") + closeCmd.Flags().BoolP("force", "f", false, "Force close pinned issues") rootCmd.AddCommand(closeCmd) } diff --git a/internal/storage/sqlite/events.go b/internal/storage/sqlite/events.go index b34e301f..7b7aaf24 100644 --- a/internal/storage/sqlite/events.go +++ b/internal/storage/sqlite/events.go @@ -111,15 +111,17 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e var stats types.Statistics // Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately) + // (bd-6v2: also count pinned 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 = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone + 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) + `).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.TombstoneIssues, &stats.PinnedIssues) if err != nil { return nil, fmt.Errorf("failed to get issue counts: %w", err) } diff --git a/internal/types/types.go b/internal/types/types.go index 6f6f4bfd..48013a5e 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -186,12 +186,13 @@ const ( StatusBlocked Status = "blocked" StatusClosed Status = "closed" StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8) + StatusPinned Status = "pinned" // Persistent bead that stays open indefinitely (bd-6v2) ) // IsValid checks if the status value is valid (built-in statuses only) func (s Status) IsValid() bool { switch s { - case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed, StatusTombstone: + case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed, StatusTombstone, StatusPinned: return true } return false @@ -390,6 +391,7 @@ type Statistics struct { BlockedIssues int `json:"blocked_issues"` ReadyIssues int `json:"ready_issues"` TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt) + PinnedIssues int `json:"pinned_issues"` // Persistent issues (bd-6v2) EpicsEligibleForClosure int `json:"epics_eligible_for_closure"` AverageLeadTime float64 `json:"average_lead_time_hours"` }