Merge upstream/main into subtle-ux-improvements

Resolves conflicts and converts new defer/undefer commands from
fatih/color to the lipgloss semantic color system.

Key changes:
- Added StatusDeferred case in graph.go with ui.RenderAccent
- Converted status.go to use ui package for colorized output
- Converted defer.go/undefer.go to use ui package
- Merged GroupID and Aliases for status command
- Updated pre-commit hook version to 0.31.0
- Ran go mod tidy to remove fatih/color dependency
This commit is contained in:
Ryan Snodgrass
2025-12-20 17:22:43 -08:00
45 changed files with 850 additions and 316 deletions
+1
View File
@@ -276,6 +276,7 @@
{"id":"bd-u0g9","title":"GH#405: Prefix parsing with hyphens treats first segment as prefix","description":"Prefix me-py-toolkit gets parsed as just me- when detecting mismatches. Fix prefix parsing to handle multi-hyphen prefixes. See GitHub issue #405.","status":"tombstone","priority":2,"issue_type":"bug","created_at":"2025-12-16T01:03:18.354066-08:00","updated_at":"2025-12-17T16:11:17.070763-08:00","deleted_at":"2025-12-17T16:11:17.070763-08:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"bug"}
{"id":"bd-umbf","title":"Design contributor namespace isolation for beads pollution prevention","description":"## Problem\n\nWhen contributors work on beads-the-project using beads-the-tool, their personal work-tracking issues leak into PRs. The .beads/issues.jsonl is intentionally tracked (it's the project's issue database), but contributors' local issues pollute the diff.\n\nThis is a recursion problem unique to self-hosting projects.\n\n## Possible Solutions to Explore\n\n1. **Contributor namespaces** - Each contributor gets a private prefix (e.g., `bd-steve-xxxx`) that's gitignored or filtered\n2. **Separate database** - Contributors use BEADS_DIR pointing elsewhere for personal tracking\n3. **Issue ownership/visibility flags** - Mark issues as \"local-only\" vs \"project\"\n4. **Prefix-based filtering** - Configure which prefixes are committed vs ignored\n\n## Design Considerations\n\n- Should be zero-friction for contributors (no manual setup)\n- Must not break existing workflows\n- Needs to work with sync/collaboration features\n- Consider: what if a \"personal\" issue graduates to \"project\" issue?\n\n## Expansion Needed\n\nThis is a placeholder. Needs detailed design exploration before implementation.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-13T18:00:29.638743-08:00","updated_at":"2025-12-13T18:00:41.345673-08:00"}
{"id":"bd-uqfn","title":"Work on beads-wkt: Output control parameters for MCP tool...","description":"Work on beads-wkt: Output control parameters for MCP tools (GH#622). Add brief, fields, max_description_length params to ready/list/show. When done, submit MR (not PR) to integration branch for Refinery.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T22:57:10.675535-08:00","updated_at":"2025-12-20T00:26:12.606293-08:00","closed_at":"2025-12-19T23:28:25.362931-08:00"}
{"id":"bd-usro","title":"Rename 'template instantiate' to 'mol bond'","description":"Rename the template instantiation command to match molecule metaphor.\n\nCurrent: bd template instantiate \u003cid\u003e --var key=value\nTarget: bd mol bond \u003cid\u003e --var key=value\n\nChanges needed:\n- Add 'mol' command group (or extend existing)\n- Add 'bond' subcommand that wraps template instantiate logic\n- Keep 'template instantiate' as deprecated alias for backward compat\n- Update help text and docs to use molecule terminology\n\nThe 'bond' verb captures:\n1. Chemistry metaphor (molecules bond to form structures)\n2. Dependency linking (child issues bonded in a DAG)\n3. Short and active\n\nSee also: molecule execution model in Gas Town","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-20T16:56:37.582795-08:00","updated_at":"2025-12-20T16:56:37.582795-08:00"}
{"id":"bd-uutv","title":"Work on beads-rs0: Namepool configuration for themed pole...","description":"Work on beads-rs0: Namepool configuration for themed polecat names. See bd show beads-rs0 for full details.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-19T21:49:48.129778-08:00","updated_at":"2025-12-19T21:59:25.565894-08:00","closed_at":"2025-12-19T21:59:25.565894-08:00"}
{"id":"bd-vgi5","title":"Push version bump to GitHub","description":"git push origin main - triggers CI but no release yet.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-18T22:43:05.363604-08:00","updated_at":"2025-12-18T22:46:57.50777-08:00","closed_at":"2025-12-18T22:46:57.50777-08:00","dependencies":[{"issue_id":"bd-vgi5","depends_on_id":"bd-qqc","type":"parent-child","created_at":"2025-12-18T22:43:16.87736-08:00","created_by":"daemon"},{"issue_id":"bd-vgi5","depends_on_id":"bd-3ggb","type":"blocks","created_at":"2025-12-18T22:43:21.078208-08:00","created_by":"daemon"}]}
{"id":"bd-vpan","title":"Re: Thread Test 2","description":"Got your message. Testing reply feature.","status":"tombstone","priority":2,"issue_type":"message","created_at":"2025-12-16T18:21:29.144352-08:00","updated_at":"2025-12-17T16:11:17.070763-08:00","dependencies":[{"issue_id":"bd-vpan","depends_on_id":"bd-x36g","type":"replies-to","created_at":"2025-12-18T13:45:31.137191-08:00","created_by":"migration"}],"deleted_at":"2025-12-17T16:11:17.070763-08:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"message"}
+1 -1
View File
@@ -9,7 +9,7 @@
"name": "beads",
"source": "./",
"description": "AI-supervised issue tracker for coding workflows",
"version": "0.30.7"
"version": "0.31.0"
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "beads",
"description": "AI-supervised issue tracker for coding workflows. Manage tasks, discover work, and maintain context with simple CLI commands.",
"version": "0.30.7",
"version": "0.31.0",
"author": {
"name": "Steve Yegge",
"url": "https://github.com/steveyegge"
+75
View File
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.31.0] - 2025-12-20
### Added
- **`bd defer` / `bd undefer` commands** (bd-4jr) - New "deferred" status for icebox issues
- Issues that are deliberately postponed, not blocked by dependencies
- Deferred issues excluded from `bd ready` and shown with ❄️ snowflake styling
- Full support in MCP server, graph views, and statistics
- **Agent audit trail** (GH#649) - Append-only logging for AI agent interactions
- New `.beads/interactions.jsonl` audit file
- `bd audit record` - Log LLM calls, tool calls, or pipe JSON via stdin
- `bd audit label <id>` - Append quality labels (good/bad) for dataset curation
- `bd compact --audit` - Optionally log compaction prompts/responses
- Audit entries are immutable; labels create new referencing entries
- **Directory-aware label scoping** (GH#541) - Auto-filter issues by directory in monorepos
- Configure `directory.labels` to map paths to label filters
- `bd ready` and `bd list` auto-apply when in matching directories
- Example: `packages/maverick: maverick` shows only maverick-labeled issues
- **Molecules catalog** (gt-0ei3) - Separate storage for template molecules
- Templates now live in `molecules.jsonl`, distinct from work items
- Hierarchical loading: built-in → town → user → project
- Molecules use `mol-*` ID namespace with `is_template: true`
- **Windows winget manifest** (GH#524) - Prepare beads for Windows Package Manager
- Added manifest files for winget submission
- Once merged to microsoft/winget-pkgs: `winget install SteveYegge.beads`
- **Git commit configuration** (GH#600) - Control beads auto-commit behavior
- `git.author` - Override commit author (useful for bots)
- `git.no-gpg-sign` - Disable GPG signing (fixes Touch ID prompts)
- **Require description config** (GH#596) - Enforce descriptions on issue creation
- Set `create.require-description: true` to error on missing descriptions
- Also supports `BD_CREATE_REQUIRE_DESCRIPTION` env var
### Changed
- **`bd stats` merged into `bd status`** (GH#644) - Consolidated status commands
- `stats` now an alias for `status`
- Colorized output with emoji header
- Shows all statistics (tombstones, pinned, epics, lead time)
- Added `--no-activity` flag to skip git activity parsing
- **Thin hook shims** (GH#615) - Hooks now delegate to `bd hooks run`
- Eliminates hook version drift after upgrades
- No more manual `bd hooks install --force` needed
- Shims use `# bd-shim v1` marker (format version, not bd version)
- **MCP context tool consolidation** - Merged 3 tools into 1
- Combined `set_context`, `where_am_i`, `init` into single `context` tool
- Actions: `set` (default with workspace_root), `show` (default), `init`
- **Doctor improvements** (GH#656) - Enhanced diagnostics and testing
- Visual improvements with grouped output
- Comprehensive test coverage added
- Fixed gosec warnings
- Contributed by @rsnodgrass
### Fixed
- **`relates-to` cycle detection** (GH#661) - Exclude relates-to from cycle detection
- Relates-to links are bidirectional by design, not cycles
- **Doctor `.local_version` check** (GH#662) - Check correct version file
- Now checks `.local_version` instead of deprecated `LastBdVersion`
- **Doctor Claude plugin link** (GH#623) - Updated broken documentation link
- **Read-only gitignore in stealth mode** (GH#663) - Print manual instructions instead of failing
- When global gitignore is read-only (e.g., symlink to immutable location), shows what to add manually
- Contributed by @qmx
## [0.30.7] - 2025-12-19
### Fixed
+1
View File
@@ -77,6 +77,7 @@ const (
StatusOpen = types.StatusOpen
StatusInProgress = types.StatusInProgress
StatusBlocked = types.StatusBlocked
StatusDeferred = types.StatusDeferred
StatusClosed = types.StatusClosed
)
+1 -1
View File
@@ -33,7 +33,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"
+1 -1
View File
@@ -421,7 +421,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)")
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"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 {
fmt.Printf("%s Deferred %s\n", ui.RenderAccent("*"), 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 {
fmt.Printf("%s Deferred %s\n", ui.RenderAccent("*"), 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)
}
+3 -1
View File
@@ -490,6 +490,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:
@@ -736,7 +738,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
+48 -38
View File
@@ -5,11 +5,10 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/configfile"
)
// CheckCLIVersion checks if the CLI version is up to date.
@@ -53,62 +52,73 @@ func CheckCLIVersion(cliVersion string) DoctorCheck {
}
}
// CheckMetadataVersionTracking checks if metadata.json has proper version tracking.
// localVersionFile is the gitignored file that stores the last bd version used locally.
// Must match the constant in version_tracking.go.
const localVersionFile = ".local_version"
// CheckMetadataVersionTracking checks if version tracking is properly configured.
// Version tracking uses .local_version file (gitignored) to track the last bd version used.
//
// GH#662: This was updated to check .local_version instead of metadata.json:LastBdVersion,
// which is now deprecated.
func CheckMetadataVersionTracking(path string, currentVersion string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
localVersionPath := filepath.Join(beadsDir, localVersionFile)
// Load metadata.json
cfg, err := configfile.Load(beadsDir)
// Read .local_version file
// #nosec G304 - path is constructed from controlled beadsDir + constant
data, err := os.ReadFile(localVersionPath)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist yet - will be created on next bd command
return DoctorCheck{
Name: "Version Tracking",
Status: StatusWarning,
Message: "Version tracking not initialized",
Detail: "The .local_version file will be created on next bd command",
Fix: "Run any bd command (e.g., 'bd ready') to initialize version tracking",
}
}
// Other error reading file
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusError,
Message: "Unable to read metadata.json",
Message: "Unable to read .local_version file",
Detail: err.Error(),
Fix: "Ensure metadata.json exists and is valid JSON. Run 'bd init' if needed.",
Fix: "Check file permissions on .beads/.local_version",
}
}
// Check if metadata.json exists
if cfg == nil {
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusWarning,
Message: "metadata.json not found",
Fix: "Run any bd command to create metadata.json with version tracking",
}
}
lastVersion := strings.TrimSpace(string(data))
// Check if LastBdVersion field is present
if cfg.LastBdVersion == "" {
// Check if file is empty
if lastVersion == "" {
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusWarning,
Message: "LastBdVersion field is empty (first run)",
Message: ".local_version file is empty",
Detail: "Version tracking will be initialized on next command",
Fix: "Run any bd command to initialize version tracking",
}
}
// Validate that LastBdVersion is a valid semver-like string
// Simple validation: should be X.Y.Z format where X, Y, Z are numbers
if !IsValidSemver(cfg.LastBdVersion) {
// Validate that version is a valid semver-like string
if !IsValidSemver(lastVersion) {
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("LastBdVersion has invalid format: %q", cfg.LastBdVersion),
Message: fmt.Sprintf("Invalid version format in .local_version: %q", lastVersion),
Detail: "Expected semver format like '0.24.2'",
Fix: "Run any bd command to reset version tracking to current version",
}
}
// Check if LastBdVersion is very old (> 10 versions behind)
// Calculate version distance
versionDiff := CompareVersions(currentVersion, cfg.LastBdVersion)
// Check if version is very old (> 10 versions behind)
versionDiff := CompareVersions(currentVersion, lastVersion)
if versionDiff > 0 {
// Current version is newer - check how far behind
currentParts := ParseVersionParts(currentVersion)
lastParts := ParseVersionParts(cfg.LastBdVersion)
lastParts := ParseVersionParts(lastVersion)
// Simple heuristic: warn if minor version is 10+ behind or major version differs by 1+
majorDiff := currentParts[0] - lastParts[0]
@@ -116,27 +126,27 @@ func CheckMetadataVersionTracking(path string, currentVersion string) DoctorChec
if majorDiff >= 1 || (majorDiff == 0 && minorDiff >= 10) {
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("LastBdVersion is very old: %s (current: %s)", cfg.LastBdVersion, currentVersion),
Message: fmt.Sprintf("Last recorded version is very old: %s (current: %s)", lastVersion, currentVersion),
Detail: "You may have missed important upgrade notifications",
Fix: "Run 'bd upgrade review' to see recent changes",
}
}
// Version is behind but not too old
// Version is behind but not too old - this is normal after upgrade
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", cfg.LastBdVersion, currentVersion),
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", lastVersion, currentVersion),
}
}
// Version is current or ahead (shouldn't happen, but handle it)
// Version is current or ahead
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (version: %s)", cfg.LastBdVersion),
Message: fmt.Sprintf("Version tracking active (version: %s)", lastVersion),
}
}
+18 -50
View File
@@ -877,89 +877,57 @@ func TestGetClaudePluginVersion(t *testing.T) {
}
func TestCheckMetadataVersionTracking(t *testing.T) {
// GH#662: Tests updated to use .local_version file instead of metadata.json:LastBdVersion
tests := []struct {
name string
setupMetadata func(beadsDir string) error
setupVersion func(beadsDir string) error
expectedStatus string
expectWarning bool
}{
{
name: "valid current version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": Version,
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(Version+"\n"), 0644)
},
expectedStatus: doctor.StatusOK,
expectWarning: false,
},
{
name: "slightly outdated version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "0.24.0",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.24.0\n"), 0644)
},
expectedStatus: doctor.StatusOK,
expectWarning: false,
},
{
name: "very old version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "0.14.0",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.14.0\n"), 0644)
},
expectedStatus: doctor.StatusWarning,
expectWarning: true,
},
{
name: "empty version field",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
name: "empty version file",
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(""), 0644)
},
expectedStatus: doctor.StatusWarning,
expectWarning: true,
},
{
name: "invalid version format",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "invalid-version",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("invalid-version\n"), 0644)
},
expectedStatus: doctor.StatusWarning,
expectWarning: true,
},
{
name: "corrupted metadata.json",
setupMetadata: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte("{invalid json}"), 0644)
},
expectedStatus: doctor.StatusError,
expectWarning: false,
},
{
name: "missing metadata.json",
setupMetadata: func(beadsDir string) error {
// Don't create metadata.json
name: "missing .local_version file",
setupVersion: func(beadsDir string) error {
// Don't create .local_version
return nil
},
expectedStatus: doctor.StatusWarning,
@@ -975,8 +943,8 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
t.Fatal(err)
}
// Setup metadata.json
if err := tc.setupMetadata(beadsDir); err != nil {
// Setup .local_version file
if err := tc.setupVersion(beadsDir); err != nil {
t.Fatal(err)
}
+6
View File
@@ -384,6 +384,9 @@ func renderNodeBox(node *GraphNode, width int) string {
case types.StatusBlocked:
statusIcon = "●"
titleStr = ui.RenderFail(padRight(title, width-4))
case types.StatusDeferred:
statusIcon = "❄"
titleStr = ui.RenderAccent(padRight(title, width-4))
case types.StatusClosed:
statusIcon = "✓"
titleStr = ui.RenderPass(padRight(title, width-4))
@@ -461,6 +464,9 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB
case types.StatusBlocked:
statusIcon = "●"
titleStr = ui.RenderFail(padRight(title, width-4))
case types.StatusDeferred:
statusIcon = "❄"
titleStr = ui.RenderAccent(padRight(title, width-4))
case types.StatusClosed:
statusIcon = "✓"
titleStr = ui.RenderPass(padRight(title, width-4))
+6 -6
View File
@@ -409,7 +409,7 @@ func uninstallHooks() error {
// runPreCommitHook flushes pending changes to JSONL before commit.
// Returns 0 on success (or if not applicable), non-zero on error.
//
//nolint:unparam // Always returns 0 by design - warns but doesn't block commits
//nolint:unparam // Always returns 0 by design - warnings don't block commits
func runPreCommitHook() int {
// Check if we're in a bd workspace
if _, err := os.Stat(".beads"); os.IsNotExist(err) {
@@ -433,7 +433,7 @@ func runPreCommitHook() int {
// Stage all tracked JSONL files
for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} {
if _, err := os.Stat(f); err == nil {
// #nosec G204 -- f is a fixed string from the hardcoded slice above
// #nosec G204 - f is from hardcoded list above, not user input
gitAdd := exec.Command("git", "add", f)
_ = gitAdd.Run() // Ignore errors - file may not exist
}
@@ -445,7 +445,7 @@ func runPreCommitHook() int {
// runPostMergeHook imports JSONL after pull/merge.
// Returns 0 on success (or if not applicable), non-zero on error.
//
//nolint:unparam // Always returns 0 by design - warns but doesn't block merges
//nolint:unparam // Always returns 0 by design - warnings don't block merges
func runPostMergeHook() int {
// Skip during rebase
if isRebaseInProgress() {
@@ -510,7 +510,7 @@ func runPrePushHook() int {
files = append(files, f)
} else {
// Check if tracked by git
// #nosec G204 -- f is a fixed string from the hardcoded slice above
// #nosec G204 - f is from hardcoded list above, not user input
checkCmd := exec.Command("git", "ls-files", "--error-unmatch", f)
if checkCmd.Run() == nil {
files = append(files, f)
@@ -524,7 +524,7 @@ func runPrePushHook() int {
// Check for uncommitted changes using git status
args := append([]string{"status", "--porcelain", "--"}, files...)
// #nosec G204 -- args contains only fixed strings from hardcoded slice
// #nosec G204 - args built from hardcoded list and git subcommands
statusCmd := exec.Command("git", args...)
output, _ := statusCmd.Output()
if len(output) > 0 {
@@ -548,7 +548,7 @@ func runPrePushHook() int {
// args: [previous-HEAD, new-HEAD, flag] where flag=1 for branch checkout
// Returns 0 on success (or if not applicable), non-zero on error.
//
//nolint:unparam // Always returns 0 by design - warns but doesn't block checkouts
//nolint:unparam // Always returns 0 by design - warnings don't block checkouts
func runPostCheckoutHook(args []string) int {
// Only run on branch checkouts (flag=1)
if len(args) >= 3 && args[2] != "1" {
+18
View File
@@ -288,6 +288,24 @@ type VersionChange struct {
// versionChanges contains agent-actionable changes for recent versions
var versionChanges = []VersionChange{
{
Version: "0.31.0",
Date: "2025-12-20",
Changes: []string{
"NEW: bd defer/bd undefer commands - Deferred status for icebox issues (bd-4jr)",
"NEW: Agent audit trail - .beads/interactions.jsonl with bd audit record/label (GH#649)",
"NEW: Directory-aware label scoping for monorepos (GH#541) - Auto-filter by directory.labels config",
"NEW: Molecules catalog - Templates in separate molecules.jsonl with hierarchical loading",
"NEW: Git commit config - git.author and git.no-gpg-sign options (GH#600)",
"NEW: create.require-description config option (GH#596)",
"CHANGED: bd stats merged into bd status (GH#644) - stats is now alias, colorized output",
"CHANGED: Thin hook shims (GH#615) - Hooks delegate to bd hooks run, no more version drift",
"CHANGED: MCP context tool consolidation - set_context/where_am_i/init merged into single context tool",
"FIX: relates-to excluded from cycle detection (GH#661)",
"FIX: Doctor checks .local_version instead of deprecated LastBdVersion (GH#662)",
"FIX: Read-only gitignore in stealth mode prints manual instructions (GH#663)",
},
},
{
Version: "0.30.7",
Date: "2025-12-19",
+13 -1
View File
@@ -1489,7 +1489,19 @@ func setupGlobalGitIgnore(homeDir string, projectPath string, verbose bool) erro
// Write the updated ignore file
// #nosec G306 - config file needs 0644
if err := os.WriteFile(ignorePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write global gitignore: %w", err)
fmt.Printf("\nUnable to write to %s (file is read-only)\n\n", ignorePath)
fmt.Printf("To enable stealth mode, add these lines to your global gitignore:\n\n")
if !hasBeads || !hasClaude {
fmt.Printf("# Beads stealth mode: %s\n", projectPath)
}
if !hasBeads {
fmt.Printf("%s\n", beadsPattern)
}
if !hasClaude {
fmt.Printf("%s\n", claudePattern)
}
fmt.Println()
return nil
}
if verbose {
+89
View File
@@ -1047,3 +1047,92 @@ func TestSetupClaudeSettings_NoExistingFile(t *testing.T) {
t.Error("File should contain bd onboard prompt")
}
}
// TestSetupGlobalGitIgnore_ReadOnly verifies graceful handling when the
// gitignore file cannot be written (prints manual instructions instead of failing).
func TestSetupGlobalGitIgnore_ReadOnly(t *testing.T) {
t.Run("read-only file", func(t *testing.T) {
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".config", "git")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
ignorePath := filepath.Join(configDir, "ignore")
if err := os.WriteFile(ignorePath, []byte("# existing\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(ignorePath, 0444); err != nil {
t.Fatal(err)
}
defer os.Chmod(ignorePath, 0644)
output := captureStdout(t, func() error {
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
})
if !strings.Contains(output, "Unable to write") {
t.Error("expected instructions for manual addition")
}
if !strings.Contains(output, "/test/project/.beads/") {
t.Error("expected .beads pattern in output")
}
})
t.Run("symlink to read-only file", func(t *testing.T) {
tmpDir := t.TempDir()
// Target file in a separate location
targetDir := filepath.Join(tmpDir, "target")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
targetFile := filepath.Join(targetDir, "ignore")
if err := os.WriteFile(targetFile, []byte("# existing\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(targetFile, 0444); err != nil {
t.Fatal(err)
}
defer os.Chmod(targetFile, 0644)
// Symlink from expected location
configDir := filepath.Join(tmpDir, ".config", "git")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(targetFile, filepath.Join(configDir, "ignore")); err != nil {
t.Fatal(err)
}
output := captureStdout(t, func() error {
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
})
if !strings.Contains(output, "Unable to write") {
t.Error("expected instructions for manual addition")
}
if !strings.Contains(output, "/test/project/.beads/") {
t.Error("expected .beads pattern in output")
}
})
}
func captureStdout(t *testing.T, fn func() error) string {
t.Helper()
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := fn()
w.Close()
var buf bytes.Buffer
buf.ReadFrom(r)
os.Stdout = oldStdout
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return buf.String()
}
+1 -1
View File
@@ -621,7 +621,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)")
+1 -85
View File
@@ -251,90 +251,7 @@ var blockedCmd = &cobra.Command{
}
},
}
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show statistics",
Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// If daemon is running, use RPC
if daemonClient != nil {
resp, err := daemonClient.Stats()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var stats types.Statistics
if err := json.Unmarshal(resp.Data, &stats); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(stats)
return
}
fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊"))
fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf("Closed: %d\n", stats.ClosedIssues)
fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
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)
}
fmt.Println()
return
}
// Direct mode
ctx := rootCtx
stats, err := store.GetStatistics(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// If no issues found, check if git has issues and auto-import
if stats.TotalIssues == 0 {
if checkAndAutoImport(ctx, store) {
// Re-run the stats after import
stats, err = store.GetStatistics(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}
if jsonOutput {
outputJSON(stats)
return
}
fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊"))
fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf("Closed: %d\n", stats.ClosedIssues)
fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
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", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure)))
}
if stats.AverageLeadTime > 0 {
fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
fmt.Println()
},
}
func init() {
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
@@ -346,5 +263,4 @@ func init() {
readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)")
rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd)
rootCmd.AddCommand(statsCmd)
}
+1 -1
View File
@@ -371,7 +371,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)")
+3 -3
View File
@@ -26,8 +26,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{
@@ -108,7 +108,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)
+69 -58
View File
@@ -11,24 +11,15 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
// StatusOutput represents the complete status output
type StatusOutput struct {
Summary *StatusSummary `json:"summary"`
Summary *types.Statistics `json:"summary"`
RecentActivity *RecentActivitySummary `json:"recent_activity,omitempty"`
}
// StatusSummary represents counts by state
type StatusSummary struct {
TotalIssues int `json:"total_issues"`
OpenIssues int `json:"open_issues"`
InProgressIssues int `json:"in_progress_issues"`
BlockedIssues int `json:"blocked_issues"`
ClosedIssues int `json:"closed_issues"`
ReadyIssues int `json:"ready_issues"`
}
// RecentActivitySummary represents activity from git history
type RecentActivitySummary struct {
HoursTracked int `json:"hours_tracked"`
@@ -43,11 +34,13 @@ type RecentActivitySummary struct {
var statusCmd = &cobra.Command{
Use: "status",
GroupID: "views",
Short: "Show issue database overview",
Long: `Show a quick snapshot of the issue database state.
Aliases: []string{"stats"},
Short: "Show issue database overview and statistics",
Long: `Show a quick snapshot of the issue database state and statistics.
This command provides a summary of issue counts by state (open, in_progress,
blocked, closed), ready work, and recent activity over the last 24 hours from git history.
blocked, closed), ready work, extended statistics (tombstones, pinned issues,
average lead time), and recent activity over the last 24 hours from git history.
Similar to how 'git status' shows working tree state, 'bd status' gives you
a quick overview of your issue database without needing multiple queries.
@@ -59,13 +52,15 @@ Use cases:
- Daily standup reference
Examples:
bd status # Show summary
bd status # Show summary with activity
bd status --no-activity # Skip git activity (faster)
bd status --json # JSON format output
bd status --assigned # Show issues assigned to current user
bd status --all # Show all issues (same as default)`,
bd stats # Alias for bd status`,
Run: func(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all")
showAssigned, _ := cmd.Flags().GetBool("assigned")
noActivity, _ := cmd.Flags().GetBool("no-activity")
jsonFormat, _ := cmd.Flags().GetBool("json")
// Override global jsonOutput if --json flag is set
@@ -109,28 +104,23 @@ Examples:
}
}
// Build summary
summary := &StatusSummary{
TotalIssues: stats.TotalIssues,
OpenIssues: stats.OpenIssues,
InProgressIssues: stats.InProgressIssues,
BlockedIssues: stats.BlockedIssues,
ClosedIssues: stats.ClosedIssues,
ReadyIssues: stats.ReadyIssues,
// Filter by assignee if requested (overrides stats with filtered counts)
if showAssigned {
stats = getAssignedStatistics(actor)
if stats == nil {
fmt.Fprintf(os.Stderr, "Error: failed to get assigned statistics\n")
os.Exit(1)
}
}
// Get recent activity from git history (last 24 hours)
// Get recent activity from git history (last 24 hours) unless --no-activity
var recentActivity *RecentActivitySummary
recentActivity = getGitActivity(24)
// Filter by assignee if requested
if showAssigned {
// Get filtered statistics for assigned issues
summary = getAssignedStatus(actor)
if !noActivity {
recentActivity = getGitActivity(24)
}
output := &StatusOutput{
Summary: summary,
Summary: stats,
RecentActivity: recentActivity,
}
@@ -140,25 +130,43 @@ Examples:
return
}
// Human-readable output
fmt.Println("\nIssue Database Status")
fmt.Println("=====================")
fmt.Printf("\nSummary:\n")
fmt.Printf(" Total Issues: %d\n", summary.TotalIssues)
fmt.Printf(" Open: %d\n", summary.OpenIssues)
fmt.Printf(" In Progress: %d\n", summary.InProgressIssues)
fmt.Printf(" Blocked: %d\n", summary.BlockedIssues)
fmt.Printf(" Closed: %d\n", summary.ClosedIssues)
fmt.Printf(" Ready to Work: %d\n", summary.ReadyIssues)
// Human-readable colorized output using semantic ui package
fmt.Printf("\n%s Issue Database Status\n\n", ui.RenderAccent("📊"))
fmt.Printf("Summary:\n")
fmt.Printf(" Total Issues: %d\n", stats.TotalIssues)
fmt.Printf(" Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf(" In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf(" Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
fmt.Printf(" Closed: %d\n", stats.ClosedIssues)
fmt.Printf(" Ready to Work: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
// Extended statistics (only show if non-zero)
hasExtended := stats.TombstoneIssues > 0 || stats.PinnedIssues > 0 ||
stats.EpicsEligibleForClosure > 0 || stats.AverageLeadTime > 0
if hasExtended {
fmt.Printf("\nExtended:\n")
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", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure)))
}
if stats.AverageLeadTime > 0 {
fmt.Printf(" Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
}
if recentActivity != nil {
fmt.Printf("\nRecent Activity (last %d hours, from git history):\n", recentActivity.HoursTracked)
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
fmt.Printf("\nRecent Activity (last %d hours):\n", recentActivity.HoursTracked)
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
}
// Show hint for more details
@@ -268,8 +276,8 @@ func getGitActivity(hours int) *RecentActivitySummary {
return activity
}
// getAssignedStatus returns status summary for issues assigned to a specific user
func getAssignedStatus(assignee string) *StatusSummary {
// getAssignedStatistics returns statistics for issues assigned to a specific user
func getAssignedStatistics(assignee string) *types.Statistics {
if store == nil {
return nil
}
@@ -287,7 +295,7 @@ func getAssignedStatus(assignee string) *StatusSummary {
return nil
}
summary := &StatusSummary{
stats := &types.Statistics{
TotalIssues: len(issues),
}
@@ -295,13 +303,15 @@ func getAssignedStatus(assignee string) *StatusSummary {
for _, issue := range issues {
switch issue.Status {
case types.StatusOpen:
summary.OpenIssues++
stats.OpenIssues++
case types.StatusInProgress:
summary.InProgressIssues++
stats.InProgressIssues++
case types.StatusBlocked:
summary.BlockedIssues++
stats.BlockedIssues++
case types.StatusDeferred:
stats.DeferredIssues++
case types.StatusClosed:
summary.ClosedIssues++
stats.ClosedIssues++
}
}
@@ -311,15 +321,16 @@ func getAssignedStatus(assignee string) *StatusSummary {
}
readyIssues, err := store.GetReadyWork(ctx, readyFilter)
if err == nil {
summary.ReadyIssues = len(readyIssues)
stats.ReadyIssues = len(readyIssues)
}
return summary
return stats
}
func init() {
statusCmd.Flags().Bool("all", false, "Show all issues (default behavior)")
statusCmd.Flags().Bool("assigned", false, "Show issues assigned to current user")
statusCmd.Flags().Bool("no-activity", false, "Skip git activity tracking (faster)")
// Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(statusCmd)
}
+19 -29
View File
@@ -112,19 +112,9 @@ func TestStatusCommand(t *testing.T) {
t.Errorf("Expected 1 closed issue, got %d", stats.ClosedIssues)
}
// Test status output structures
summary := &StatusSummary{
TotalIssues: stats.TotalIssues,
OpenIssues: stats.OpenIssues,
InProgressIssues: stats.InProgressIssues,
BlockedIssues: stats.BlockedIssues,
ClosedIssues: stats.ClosedIssues,
ReadyIssues: stats.ReadyIssues,
}
// Test JSON marshaling
// Test JSON marshaling with full Statistics
output := &StatusOutput{
Summary: summary,
Summary: stats,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
@@ -178,7 +168,7 @@ func TestGetGitActivity(t *testing.T) {
}
}
func TestGetAssignedStatus(t *testing.T) {
func TestGetAssignedStatistics(t *testing.T) {
// Create a temporary directory for the test database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, ".beads", "test.db")
@@ -202,7 +192,7 @@ func TestGetAssignedStatus(t *testing.T) {
t.Fatalf("Failed to set issue prefix: %v", err)
}
// Set global store and rootCtx for getAssignedStatus
// Set global store and rootCtx for getAssignedStatistics
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
@@ -239,29 +229,29 @@ func TestGetAssignedStatus(t *testing.T) {
}
}
// Test getAssignedStatus for Alice
summary := getAssignedStatus("alice")
if summary == nil {
t.Fatal("getAssignedStatus returned nil")
// Test getAssignedStatistics for Alice
stats := getAssignedStatistics("alice")
if stats == nil {
t.Fatal("getAssignedStatistics returned nil")
}
if summary.TotalIssues != 2 {
t.Errorf("Expected 2 issues for alice, got %d", summary.TotalIssues)
if stats.TotalIssues != 2 {
t.Errorf("Expected 2 issues for alice, got %d", stats.TotalIssues)
}
if summary.OpenIssues != 1 {
t.Errorf("Expected 1 open issue for alice, got %d", summary.OpenIssues)
if stats.OpenIssues != 1 {
t.Errorf("Expected 1 open issue for alice, got %d", stats.OpenIssues)
}
if summary.InProgressIssues != 1 {
t.Errorf("Expected 1 in-progress issue for alice, got %d", summary.InProgressIssues)
if stats.InProgressIssues != 1 {
t.Errorf("Expected 1 in-progress issue for alice, got %d", stats.InProgressIssues)
}
// Test for Bob
bobSummary := getAssignedStatus("bob")
if bobSummary == nil {
t.Fatal("getAssignedStatus returned nil for bob")
bobStats := getAssignedStatistics("bob")
if bobStats == nil {
t.Fatal("getAssignedStatistics returned nil for bob")
}
if bobSummary.TotalIssues != 1 {
t.Errorf("Expected 1 issue for bob, got %d", bobSummary.TotalIssues)
if bobStats.TotalIssues != 1 {
t.Errorf("Expected 1 issue for bob, got %d", bobStats.TotalIssues)
}
}
+1
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
#
# bd (beads) post-checkout hook - thin shim
#
+1
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
#
# bd (beads) post-merge hook - thin shim
#
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.30.7
# bd-hooks-version: 0.31.0
#
# bd (beads) pre-commit hook - thin shim
#
+1
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
#
# bd (beads) pre-push hook - thin shim
#
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"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 {
fmt.Printf("%s Undeferred %s (now open)\n", ui.RenderPass("*"), 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 {
fmt.Printf("%s Undeferred %s (now open)\n", ui.RenderPass("*"), 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)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
var (
// Version is the current version of bd (overridden by ldflags at build time)
Version = "0.30.7"
Version = "0.31.0"
// Build can be set via ldflags at compile time
Build = "dev"
// Commit and branch the git revision the binary was built from (optional ldflag)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "beads-mcp"
version = "0.30.7"
version = "0.31.0"
description = "MCP server for beads issue tracker."
readme = "README.md"
requires-python = ">=3.10"
@@ -4,4 +4,4 @@ This package provides an MCP (Model Context Protocol) server that exposes
beads (bd) issue tracker functionality to MCP Clients.
"""
__version__ = "0.30.7"
__version__ = "0.31.0"
@@ -6,7 +6,7 @@ from typing import Literal, Any
from pydantic import BaseModel, Field, field_validator
# Type aliases for issue statuses, types, and dependencies
IssueStatus = Literal["open", "in_progress", "blocked", "closed"]
IssueStatus = Literal["open", "in_progress", "blocked", "deferred", "closed"]
IssueType = Literal["bug", "feature", "task", "epic", "chore"]
DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"]
@@ -372,7 +372,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]:
"name": "list",
"description": "List all issues with optional filters",
"parameters": {
"status": "open|in_progress|blocked|closed (optional)",
"status": "open|in_progress|blocked|deferred|closed (optional)",
"priority": "int 0-4 (optional)",
"issue_type": "bug|feature|task|epic|chore (optional)",
"assignee": "str (optional)",
@@ -413,7 +413,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]:
"description": "Update an existing issue",
"parameters": {
"issue_id": "str (required)",
"status": "open|in_progress|blocked|closed (optional)",
"status": "open|in_progress|blocked|deferred|closed (optional)",
"priority": "int 0-4 (optional)",
"assignee": "str (optional)",
"title": "str (optional)",
@@ -317,7 +317,7 @@ async def beads_ready_work(
async def beads_list_issues(
status: Annotated[IssueStatus | None, "Filter by status (open, in_progress, blocked, closed)"] = None,
status: Annotated[IssueStatus | None, "Filter by status (open, in_progress, blocked, deferred, closed)"] = None,
priority: Annotated[int | None, "Filter by priority (0-4, 0=highest)"] = None,
issue_type: Annotated[IssueType | None, "Filter by type (bug, feature, task, epic, chore)"] = None,
assignee: Annotated[str | None, "Filter by assignee"] = None,
@@ -392,7 +392,7 @@ async def beads_create_issue(
async def beads_update_issue(
issue_id: Annotated[str, "Issue ID (e.g., bd-1)"],
status: Annotated[IssueStatus | None, "New status (open, in_progress, blocked, closed)"] = None,
status: Annotated[IssueStatus | None, "New status (open, in_progress, blocked, deferred, closed)"] = None,
priority: Annotated[int | None, "New priority (0-4)"] = None,
assignee: Annotated[str | None, "New assignee"] = None,
title: Annotated[str | None, "New title"] = None,
+2 -1
View File
@@ -205,8 +205,9 @@ type (
const (
StatusOpen = types.StatusOpen
StatusInProgress = types.StatusInProgress
StatusClosed = types.StatusClosed
StatusBlocked = types.StatusBlocked
StatusDeferred = types.StatusDeferred
StatusClosed = types.StatusClosed
)
// IssueType constants
+9 -4
View File
@@ -1033,7 +1033,7 @@ func (m *MemoryStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
return results, nil
}
// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked.
// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked/deferred.
// The caller must hold at least a read lock.
func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
deps := m.dependencies[issueID]
@@ -1053,7 +1053,7 @@ func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
continue
}
switch blocker.Status {
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked:
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusDeferred:
blockers = append(blockers, blocker.ID)
}
}
@@ -1082,7 +1082,8 @@ func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
}
blockers := m.getOpenBlockers(issue.ID)
if issue.Status != types.StatusBlocked && len(blockers) == 0 {
// Issue is "blocked" if: status is blocked, status is deferred, or has open blockers
if issue.Status != types.StatusBlocked && issue.Status != types.StatusDeferred && len(blockers) == 0 {
continue
}
@@ -1219,13 +1220,17 @@ func (m *MemoryStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
stats.InProgressIssues++
case types.StatusClosed:
stats.ClosedIssues++
case types.StatusDeferred:
stats.DeferredIssues++
case types.StatusTombstone:
stats.TombstoneIssues++
case types.StatusPinned:
stats.PinnedIssues++
}
}
// TotalIssues excludes tombstones (matches SQLite behavior)
stats.TotalIssues = stats.OpenIssues + stats.InProgressIssues + stats.ClosedIssues
stats.TotalIssues = stats.OpenIssues + stats.InProgressIssues + stats.ClosedIssues + stats.DeferredIssues + stats.PinnedIssues
// Second pass: calculate blocked and ready issues based on dependencies
// An issue is blocked if it has open blockers (uses same logic as GetBlockedIssues)
+1 -1
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
+2 -2
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
`
@@ -505,3 +505,141 @@ func TestDetectCyclesMixedTypes(t *testing.T) {
t.Errorf("Expected cycle of length 3, got %d", len(cycle))
}
}
// TestDetectCyclesRelatesToNotACycle tests that bidirectional relates-to links are NOT reported as cycles
// This is the fix for GitHub issue #661: relates-to relationships should be excluded from cycle detection
// because they are inherently bidirectional ("see also" links) and don't affect work ordering.
func TestDetectCyclesRelatesToNotACycle(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create two issues
issue1 := &types.Issue{Title: "Todo 1", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Todo 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Create bidirectional relates_to links (simulating what bd relate does)
// issue1 relates_to issue2
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue1.ID, issue2.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
// issue2 relates_to issue1 (the reverse link)
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue2.ID, issue1.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
// Detect cycles - should find NONE because relates_to is excluded
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) != 0 {
t.Errorf("Expected no cycles for relates_to bidirectional links, but found %d cycles", len(cycles))
for i, cycle := range cycles {
t.Logf("Cycle %d:", i+1)
for _, issue := range cycle {
t.Logf(" - %s: %s", issue.ID, issue.Title)
}
}
}
}
// TestDetectCyclesRelatesToWithOtherCycle tests that relates-to is excluded but other cycles are still detected
func TestDetectCyclesRelatesToWithOtherCycle(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create three issues
issue1 := &types.Issue{Title: "Issue A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Issue B", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue3 := &types.Issue{Title: "Issue C", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, issue3, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Create bidirectional relates_to between issue1 and issue2 (should NOT trigger cycle)
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue1.ID, issue2.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue2.ID, issue1.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
// Create a real cycle with blocks: issue2 -> issue3 -> issue2
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue2.ID, issue3.ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert blocks dependency failed: %v", err)
}
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue3.ID, issue2.ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert blocks dependency failed: %v", err)
}
// Detect cycles - should find the blocks cycle but NOT the relates_to "cycle"
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) == 0 {
t.Fatal("Expected to find the blocks cycle, but found none")
}
// Verify the cycle contains issue2 and issue3, but NOT issue1
foundIDs := make(map[string]bool)
for _, cycle := range cycles {
for _, issue := range cycle {
foundIDs[issue.ID] = true
}
}
if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] {
t.Errorf("Expected cycle to contain issue2 and issue3. Found: %v", foundIDs)
}
// Verify issue1 is NOT in the cycle (it's only connected via relates-to)
if foundIDs[issue1.ID] {
t.Errorf("issue1 should NOT be in cycle (only connected via relates-to), but was found")
}
}
+6 -1
View File
@@ -613,9 +613,12 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
}
// DetectCycles finds circular dependencies and returns the actual cycle paths
// Note: relates-to dependencies are excluded because they are intentionally bidirectional
// ("see also" relationships) and do not represent problematic cycles.
func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, error) {
// Use recursive CTE to find cycles with full paths
// We track the path as a string to work around SQLite's lack of arrays
// Exclude relates-to dependencies since they are inherently bidirectional
rows, err := s.db.QueryContext(ctx, `
WITH RECURSIVE paths AS (
SELECT
@@ -625,6 +628,7 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err
issue_id || '→' || depends_on_id as path,
0 as depth
FROM dependencies
WHERE type != 'relates-to'
UNION ALL
@@ -636,7 +640,8 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err
p.depth + 1
FROM dependencies d
JOIN paths p ON d.issue_id = p.depends_on_id
WHERE p.depth < ?
WHERE d.type != 'relates-to'
AND p.depth < ?
AND (d.depends_on_id = p.start_id OR p.path NOT LIKE '%' || d.depends_on_id || '%')
)
SELECT DISTINCT path as cycle_path
+10 -8
View File
@@ -1527,9 +1527,10 @@ func TestDetectCycles_MultipleGraphs(t *testing.T) {
}
}
// TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection tests relates-to allows bidirectional links
// even though they're technically cycles. The cycle prevention only skips relates-to during AddDependency.
func TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection(t *testing.T) {
// TestDetectCycles_RelatesTypeAllowsBidirectionalWithoutCycleReport tests relates-to allows bidirectional links
// and DetectCycles correctly excludes them (they're "see also" links, not problematic cycles).
// This was fixed in GH#661 - relates-to is explicitly excluded from cycle detection.
func TestDetectCycles_RelatesTypeAllowsBidirectionalWithoutCycleReport(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
@@ -1552,7 +1553,7 @@ func TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection(t *testing.T)
t.Fatalf("AddDependency for relates-to failed: %v", err)
}
// Add B relates-to A (this should succeed despite creating a cycle because relates-to skips cycle detection)
// Add B relates-to A (this should succeed - relates-to skips cycle prevention)
err = store.AddDependency(ctx, &types.Dependency{
IssueID: issueB.ID,
DependsOnID: issueA.ID,
@@ -1562,15 +1563,16 @@ func TestDetectCycles_RelatedTypeAllowsAdditionButReportsDetection(t *testing.T)
t.Fatalf("AddDependency for reverse relates-to failed: %v", err)
}
// DetectCycles will report the cycle even though AddDependency allowed it
// DetectCycles should NOT report relates-to as cycles (GH#661 fix)
// relates-to is inherently bidirectional ("see also") and doesn't affect work ordering
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
// Relates-to bidirectional creates cycles (may report multiple entry points for same cycle)
if len(cycles) == 0 {
t.Error("Expected at least 1 cycle detected for bidirectional relates-to")
// relates-to bidirectional should NOT be reported as a cycle
if len(cycles) != 0 {
t.Errorf("Expected 0 cycles for bidirectional relates-to (GH#661 fix), got %d", len(cycles))
}
// Verify both directions exist
+7 -5
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 {
+4 -3
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
+3 -3
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;
`
+3 -1
View File
@@ -219,6 +219,7 @@ const (
StatusOpen Status = "open"
StatusInProgress Status = "in_progress"
StatusBlocked Status = "blocked"
StatusDeferred Status = "deferred" // Deliberately put on ice for later (bd-4jr)
StatusClosed Status = "closed"
StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8)
StatusPinned Status = "pinned" // Persistent bead that stays open indefinitely (bd-6v2)
@@ -227,7 +228,7 @@ const (
// 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, StatusPinned:
case StatusOpen, StatusInProgress, StatusBlocked, StatusDeferred, StatusClosed, StatusTombstone, StatusPinned:
return true
}
return false
@@ -425,6 +426,7 @@ type Statistics struct {
InProgressIssues int `json:"in_progress_issues"`
ClosedIssues int `json:"closed_issues"`
BlockedIssues int `json:"blocked_issues"`
DeferredIssues int `json:"deferred_issues"` // Issues on ice (bd-4jr)
ReadyIssues int `json:"ready_issues"`
TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt)
PinnedIssues int `json:"pinned_issues"` // Persistent issues (bd-6v2)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@beads/bd",
"version": "0.30.7",
"version": "0.31.0",
"description": "Beads issue tracker - lightweight memory system for coding agents with native binary support",
"main": "bin/bd.js",
"bin": {