Files
beads/DESIGN.md
Steve Yegge 704515125d Initial commit: Beads issue tracker with security fixes
Core features:
- Dependency-aware issue tracking with SQLite backend
- Ready work detection (issues with no open blockers)
- Dependency tree visualization
- Cycle detection and prevention
- Full audit trail
- CLI with colored output

Security and correctness fixes applied:
- Fixed SQL injection vulnerability in UpdateIssue (whitelisted fields)
- Fixed race condition in ID generation (added mutex)
- Fixed cycle detection to return full paths (not just issue IDs)
- Added cycle prevention in AddDependency (validates before commit)
- Added comprehensive input validation (priority, status, types, etc.)
- Fixed N+1 query in GetBlockedIssues (using GROUP_CONCAT)
- Improved query building in GetReadyWork (proper string joining)
- Fixed P0 priority filter bug (using Changed() instead of value check)

All critical and major issues from code review have been addressed.

🤖 Generated with Claude Code
2025-10-11 20:07:36 -07:00

21 KiB

Beads - Dependency-Aware Issue Tracker

Tagline: "Issues chained together like beads"

Vision

A lightweight, standalone issue tracker that makes dependency graphs first-class citizens. The killer feature: automatic detection of "ready work" - issues with no open blockers.

Philosophy:

  • SQLite by default (zero setup, single binary + database file)
  • PostgreSQL for teams/scale (multi-user, better performance)
  • CLI-first, with clean output for both humans and scripts
  • Dependencies are the core primitive, not an afterthought
  • Full audit trail of all changes

Core Data Model

Issues

type Issue struct {
    ID                 string    // "bd-1", "bd-2" (beads- prefix)
    Title              string    // max 500 chars
    Description        string    // problem statement (what/why)
    Design             string    // solution design (how)
    AcceptanceCriteria string    // definition of done
    Notes              string    // working notes
    Status             Status    // open, in_progress, blocked, closed
    Priority           int       // 0 (highest) to 4 (lowest), default 2
    IssueType          IssueType // bug, feature, task, epic, chore
    Assignee           string    // optional
    EstimatedMinutes   *int      // optional
    CreatedAt          time.Time
    UpdatedAt          time.Time
    ClosedAt           *time.Time
}

type Status string
const (
    StatusOpen       Status = "open"
    StatusInProgress Status = "in_progress"
    StatusBlocked    Status = "blocked"
    StatusClosed     Status = "closed"
)

type IssueType string
const (
    TypeBug     IssueType = "bug"
    TypeFeature IssueType = "feature"
    TypeTask    IssueType = "task"
    TypeEpic    IssueType = "epic"
    TypeChore   IssueType = "chore"
)

Dependencies

type Dependency struct {
    IssueID      string         // the issue that depends
    DependsOnID  string         // the issue it depends on
    Type         DependencyType // relationship type
    CreatedAt    time.Time
    CreatedBy    string
}

type DependencyType string
const (
    DepBlocks      DependencyType = "blocks"      // hard blocker
    DepRelated     DependencyType = "related"     // soft relationship
    DepParentChild DependencyType = "parent-child" // epic/subtask
)

Labels

type Label struct {
    IssueID string
    Label   string // freeform tag
}

Events (Audit Trail)

type Event struct {
    ID         int64
    IssueID    string
    EventType  EventType
    Actor      string    // who made the change
    OldValue   *string   // before state (JSON)
    NewValue   *string   // after state (JSON)
    Comment    *string   // for comment events
    CreatedAt  time.Time
}

type EventType string
const (
    EventCreated          EventType = "created"
    EventUpdated          EventType = "updated"
    EventStatusChanged    EventType = "status_changed"
    EventCommented        EventType = "commented"
    EventClosed           EventType = "closed"
    EventReopened         EventType = "reopened"
    EventDependencyAdded  EventType = "dependency_added"
    EventDependencyRemoved EventType = "dependency_removed"
    EventLabelAdded       EventType = "label_added"
    EventLabelRemoved     EventType = "label_removed"
)

Backend Abstraction

Storage Interface

// Storage defines the interface for issue storage backends
type Storage interface {
    // Issues
    CreateIssue(ctx context.Context, issue *Issue, actor string) error
    GetIssue(ctx context.Context, id string) (*Issue, error)
    UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
    CloseIssue(ctx context.Context, id string, reason string, actor string) error
    SearchIssues(ctx context.Context, query string, filter IssueFilter) ([]*Issue, error)

    // Dependencies
    AddDependency(ctx context.Context, dep *Dependency, actor string) error
    RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error
    GetDependencies(ctx context.Context, issueID string) ([]*Issue, error)
    GetDependents(ctx context.Context, issueID string) ([]*Issue, error)
    GetDependencyTree(ctx context.Context, issueID string, maxDepth int) ([]*TreeNode, error)
    DetectCycles(ctx context.Context) ([][]*Issue, error)

    // Labels
    AddLabel(ctx context.Context, issueID, label, actor string) error
    RemoveLabel(ctx context.Context, issueID, label, actor string) error
    GetLabels(ctx context.Context, issueID string) ([]string, error)
    GetIssuesByLabel(ctx context.Context, label string) ([]*Issue, error)

    // Ready Work & Blocking
    GetReadyWork(ctx context.Context, filter WorkFilter) ([]*Issue, error)
    GetBlockedIssues(ctx context.Context) ([]*BlockedIssue, error)

    // Events
    AddComment(ctx context.Context, issueID, actor, comment string) error
    GetEvents(ctx context.Context, issueID string, limit int) ([]*Event, error)

    // Statistics
    GetStatistics(ctx context.Context) (*Statistics, error)

    // Lifecycle
    Close() error
}

type IssueFilter struct {
    Status   *Status
    Priority *int
    IssueType *IssueType
    Assignee *string
    Labels   []string
    Limit    int
}

type WorkFilter struct {
    Status   Status // default: open
    Priority *int   // filter by priority
    Assignee *string
    Limit    int    // default: 10
}

type BlockedIssue struct {
    Issue
    BlockedByCount int
    BlockedBy      []string // issue IDs
}

type TreeNode struct {
    Issue
    Depth     int
    Truncated bool // if hit max depth
}

type Statistics struct {
    TotalIssues      int
    OpenIssues       int
    InProgressIssues int
    ClosedIssues     int
    BlockedIssues    int
    ReadyIssues      int
    AverageLeadTime  float64 // hours from open to closed
}

Backend Implementations

storage/
  storage.go          // Interface definition
  sqlite/
    sqlite.go         // SQLite implementation
    migrations.go     // Schema migrations
  postgres/
    postgres.go       // PostgreSQL implementation
    migrations.go     // Schema migrations
  factory.go          // Backend factory

Factory Pattern

type Config struct {
    Backend  string // "sqlite" or "postgres"

    // SQLite config
    Path     string // default: ~/.beads/beads.db

    // PostgreSQL config
    Host     string
    Port     int
    Database string
    User     string
    Password string
    SSLMode  string
}

func NewStorage(config Config) (Storage, error) {
    switch config.Backend {
    case "sqlite":
        return sqlite.New(config.Path)
    case "postgres":
        return postgres.New(config.Host, config.Port, config.Database,
                           config.User, config.Password, config.SSLMode)
    default:
        return nil, fmt.Errorf("unknown backend: %s", config.Backend)
    }
}

Schema Design

SQLite Schema

-- Issues table
CREATE TABLE issues (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL CHECK(length(title) <= 500),
    description TEXT NOT NULL DEFAULT '',
    design TEXT NOT NULL DEFAULT '',
    acceptance_criteria TEXT NOT NULL DEFAULT '',
    notes TEXT NOT NULL DEFAULT '',
    status TEXT NOT NULL DEFAULT 'open',
    priority INTEGER NOT NULL DEFAULT 2 CHECK(priority >= 0 AND priority <= 4),
    issue_type TEXT NOT NULL DEFAULT 'task',
    assignee TEXT,
    estimated_minutes INTEGER,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    closed_at DATETIME
);

CREATE INDEX idx_issues_status ON issues(status);
CREATE INDEX idx_issues_priority ON issues(priority);
CREATE INDEX idx_issues_assignee ON issues(assignee);
CREATE INDEX idx_issues_created_at ON issues(created_at);

-- Dependencies table
CREATE TABLE dependencies (
    issue_id TEXT NOT NULL,
    depends_on_id TEXT NOT NULL,
    type TEXT NOT NULL DEFAULT 'blocks',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_by TEXT NOT NULL,
    PRIMARY KEY (issue_id, depends_on_id),
    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
    FOREIGN KEY (depends_on_id) REFERENCES issues(id) ON DELETE CASCADE
);

CREATE INDEX idx_dependencies_issue ON dependencies(issue_id);
CREATE INDEX idx_dependencies_depends_on ON dependencies(depends_on_id);

-- Labels table (many-to-many)
CREATE TABLE labels (
    issue_id TEXT NOT NULL,
    label TEXT NOT NULL,
    PRIMARY KEY (issue_id, label),
    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);

CREATE INDEX idx_labels_label ON labels(label);

-- Events table (audit trail)
CREATE TABLE events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    issue_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    actor TEXT NOT NULL,
    old_value TEXT,
    new_value TEXT,
    comment TEXT,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);

CREATE INDEX idx_events_issue ON events(issue_id);
CREATE INDEX idx_events_created_at ON events(created_at);

-- Ready work view (materialized via trigger)
-- Issues with no open dependencies
CREATE VIEW ready_issues AS
SELECT i.*
FROM issues i
WHERE i.status = 'open'
  AND NOT EXISTS (
    SELECT 1 FROM dependencies d
    JOIN issues blocked ON d.depends_on_id = blocked.id
    WHERE d.issue_id = i.id
      AND d.type = 'blocks'
      AND blocked.status IN ('open', 'in_progress', 'blocked')
  );

-- Blocked issues view
CREATE VIEW blocked_issues AS
SELECT
    i.*,
    COUNT(d.depends_on_id) as blocked_by_count
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')
  AND d.type = 'blocks'
  AND blocker.status IN ('open', 'in_progress', 'blocked')
GROUP BY i.id;

PostgreSQL Schema Extensions

PostgreSQL can leverage more advanced features:

-- Use JSONB for flexible metadata
ALTER TABLE issues ADD COLUMN metadata JSONB;
CREATE INDEX idx_issues_metadata ON issues USING GIN(metadata);

-- Use array type for labels (alternative to junction table)
-- (Keep junction table for compatibility, but could optimize)

-- Use recursive CTEs for dependency trees (more efficient)
-- Implement as stored function:
CREATE OR REPLACE FUNCTION get_dependency_tree(root_issue_id TEXT, max_depth INT DEFAULT 50)
RETURNS TABLE (
    issue_id TEXT,
    title TEXT,
    status TEXT,
    priority INT,
    depth INT,
    path TEXT[]
) AS $$
    WITH RECURSIVE tree AS (
        SELECT
            i.id as issue_id,
            i.title,
            i.status,
            i.priority,
            0 as depth,
            ARRAY[i.id] as path
        FROM issues i
        WHERE i.id = root_issue_id

        UNION ALL

        SELECT
            i.id,
            i.title,
            i.status,
            i.priority,
            t.depth + 1,
            t.path || i.id
        FROM issues i
        JOIN dependencies d ON i.id = d.depends_on_id
        JOIN tree t ON d.issue_id = t.issue_id
        WHERE t.depth < max_depth
          AND NOT (i.id = ANY(t.path)) -- cycle detection
    )
    SELECT * FROM tree ORDER BY depth, priority;
$$ LANGUAGE SQL;

-- Cycle detection using CTEs
CREATE OR REPLACE FUNCTION detect_dependency_cycles()
RETURNS TABLE (cycle_path TEXT[]) AS $$
    WITH RECURSIVE paths AS (
        SELECT
            issue_id,
            depends_on_id,
            ARRAY[issue_id, depends_on_id] as path,
            false as is_cycle
        FROM dependencies

        UNION ALL

        SELECT
            p.issue_id,
            d.depends_on_id,
            p.path || d.depends_on_id,
            d.depends_on_id = ANY(p.path)
        FROM paths p
        JOIN dependencies d ON p.depends_on_id = d.issue_id
        WHERE NOT p.is_cycle
          AND array_length(p.path, 1) < 100
    )
    SELECT DISTINCT path
    FROM paths
    WHERE is_cycle
    ORDER BY path;
$$ LANGUAGE SQL;

CLI Design

Command Structure

beads [global options] <command> [command options]

Global Options:
  --db <path>          Database path (default: ~/.beads/beads.db)
  --backend <type>     Backend type: sqlite, postgres (default: sqlite)
  --config <path>      Config file path (default: ~/.beads/config.yaml)
  --format <format>    Output format: text, json, yaml (default: text)
  --no-color           Disable colored output

Commands:
  init                 Initialize a new beads database
  create               Create a new issue
  update               Update an issue
  show                 Show issue details
  list                 List issues
  search               Search issues by text
  close                Close one or more issues
  reopen               Reopen a closed issue

  comment              Add a comment to an issue

  dep add              Add a dependency
  dep remove           Remove a dependency
  dep tree             Show dependency tree
  dep cycles           Detect dependency cycles

  label add            Add a label
  label remove         Remove a label
  label list           List all labels
  label issues         List issues with label

  ready                Show ready work (no blockers)
  blocked              Show blocked issues
  stats                Show statistics

  config               Manage configuration
  export               Export database to JSON/YAML
  import               Import from JSON/YAML
  migrate              Migrate from other issue trackers

  help                 Show help
  version              Show version

Example Commands

# Initialize
beads init                           # Creates ~/.beads/beads.db
beads init --db ./project.db         # Project-local database
beads init --backend postgres        # Interactive setup for PostgreSQL

# Create
beads create "Fix login bug" \
  --description "Users can't log in with Google OAuth" \
  --priority 1 \
  --type bug \
  --label "auth,critical"

# Update
beads update bd-1 --status in_progress --assignee "alice"

# Show
beads show bd-1                      # Full details with dependencies
beads show bd-1 --format json        # JSON output

# List
beads list                           # All open issues
beads list --status closed           # Closed issues
beads list --priority 1              # P1 issues
beads list --label "auth"            # Issues with label

# Dependencies
beads dep add bd-2 bd-1              # bd-2 depends on bd-1
beads dep tree bd-2                  # Show full tree
beads dep cycles                     # Check for cycles

# Ready work
beads ready                          # Top 10 ready issues
beads ready --limit 20 --assignee alice

# Comments
beads comment bd-1 "Started investigation"
beads comment bd-1 --file notes.md  # From file

# Close
beads close bd-1 "Fixed in commit abc123"
beads close bd-1 bd-2 bd-3 --reason "Duplicate"

# Search
beads search "oauth"                 # Full-text search
beads search "oauth" --status open   # With filters

# Stats
beads stats                          # Overall statistics
beads stats --format json            # Machine-readable

# Export/Import
beads export --output backup.json
beads import --input backup.json
beads migrate --from github --repo owner/repo

Configuration

Config File (~/.beads/config.yaml)

# Default backend
backend: sqlite

# SQLite config
sqlite:
  path: ~/.beads/beads.db

# PostgreSQL config
postgres:
  host: localhost
  port: 5432
  database: beads
  user: beads
  password: ""
  sslmode: prefer

# Display preferences
display:
  color: true
  format: text  # text, json, yaml
  date_format: "2006-01-02 15:04"

# Issue defaults
defaults:
  priority: 2
  type: task
  status: open

# ID prefix (default: "bd-")
id_prefix: "bd-"

# Actor name (for audit trail)
actor: $USER

Implementation Roadmap

Phase 1: Core Foundation

  • Project setup (go.mod, directory structure)
  • Data model (types, interfaces)
  • SQLite storage implementation
  • Basic CLI (create, list, show, update, close)
  • Dependency management (add, remove, tree)
  • Ready work detection
  • Tests

Phase 2: Polish & Features

  • Labels support
  • Comments and events
  • Full-text search
  • Cycle detection
  • Statistics
  • Colored CLI output
  • JSON/YAML output formats

Phase 3: PostgreSQL

  • PostgreSQL storage implementation
  • Config file support
  • Backend switching
  • Migration utilities
  • Performance optimization

Phase 4: Advanced

  • Export/import
  • GitHub/GitLab/Jira migration tools
  • TUI (bubble tea?)
  • Web UI (templ?)
  • API server mode
  • Multi-user workflows

Key Design Decisions

Why SQLite Default?

  1. Zero setup: Single binary + database file
  2. Portability: Database is a file, easy to backup/share
  3. Performance: More than enough for <100k issues
  4. Simplicity: No server to run
  5. Git-friendly: Can commit database file for small teams

Why Support PostgreSQL?

  1. Scale: Better for large teams (>10 people)
  2. Concurrency: Better multi-user support
  3. Features: Recursive CTEs, JSONB, full-text search
  4. Existing infrastructure: Teams already running PostgreSQL

ID Prefix: "bd-" vs "beads-"

  • bd-: Shorter, easier to type
  • beads-: More explicit
  • Configurable: Let users choose in config

I lean toward bd- for brevity.

Dependency Types

  • blocks: Hard blocker (affects ready work calculation)
  • related: Soft relationship (just for context)
  • parent-child: Epic/subtask hierarchy

Only "blocks" affects ready work detection.

Status vs. Blocked Field

Should we have a separate blocked status, or compute it dynamically?

Decision: Compute dynamically

  • blocked status is redundant with dependency graph
  • Auto-blocking based on dependencies is error-prone
  • Let users manually set blocked if they want (e.g., blocked on external dependency)
  • ready command shows what's actually unblocked

Event Storage

Full audit trail in events table. This enables:

  • Change history for issues
  • Comment threads
  • "Who changed what when" debugging
  • Potential undo/revert functionality

What to Port from VibeCoder

Keep

  • Core data model (issues, dependencies, labels, events)
  • Ready work detection algorithm
  • Dependency tree traversal
  • Cycle detection
  • CLI structure (create, update, show, list, etc.)
  • Priority system (1-5)
  • Issue types (bug, feature, task, epic, chore)

Leave Behind

  • MCP server (can add later as separate project)
  • VibeCoder-specific concepts (missions, campaigns, amps)
  • Temporal workflows
  • Web portal integration
  • Mission tracking
  • Campaign aggregation views

🤔 Maybe Later

  • Web UI (keep CLI-first)
  • API server mode
  • TUI with bubble tea
  • GitHub/GitLab sync
  • Email notifications
  • Webhooks

Go Dependencies

Minimal dependencies:

// Core
database/sql
github.com/mattn/go-sqlite3  // SQLite driver
github.com/lib/pq            // PostgreSQL driver

// CLI
github.com/spf13/cobra       // CLI framework
github.com/spf13/viper       // Config management
github.com/fatih/color       // Terminal colors

// Serialization
gopkg.in/yaml.v3             // YAML support

// Testing
github.com/stretchr/testify  // Test assertions

No frameworks, no ORMs. Keep it simple.


Open Questions

  1. Multi-database support: Should one beads installation manage multiple databases?

    • Probably yes: beads --db project1.db vs beads --db project2.db
  2. Git integration: Should beads auto-commit the database?

    • Probably no: Let users manage their own git workflow
    • But provide hooks/examples
  3. Web UI: Build one, or keep it CLI-only?

    • Start CLI-only
    • Web UI as separate project later (beads-web?)
  4. API server: Should beads run as a server?

    • Start as CLI tool
    • Add beads serve command later for HTTP API
  5. Migrations: How to handle schema changes?

    • Embed migrations in binary
    • Track schema version in database
    • Auto-migrate on startup (with backup)
  6. Concurrency: SQLite WAL mode for better concurrency?

    • Yes, enable by default
    • Document limitations (single writer at a time)
  7. Full-text search: SQLite FTS5 or simple LIKE queries?

    • Start with LIKE queries (simpler)
    • Add FTS5 in phase 2
  8. Transactions: Where do we need them?

    • Issue creation (issue + labels + event)
    • Dependency changes (dep + event + cycle check)
    • Bulk operations (close multiple issues)

Success Metrics

Beads is successful if:

  1. Installation: go install github.com/user/beads@latest just works
  2. First use: beads init && beads create "test" works in <10 seconds
  3. Performance: Can handle 10k issues with instant CLI responses
  4. Portability: Database file can be moved between machines, checked into git
  5. Adoption: Used by at least 3 other developers/teams within 6 months

Next Steps

  1. Create ~/src/beads directory structure
  2. Initialize Go module
  3. Implement core types (Issue, Dependency, Storage interface)
  4. Build SQLite storage implementation
  5. Build basic CLI (create, show, list)
  6. Test with VibeCoder's issue data (export and import)
  7. Iterate toward parity with scripts/issue.ts
  8. Release v0.1.0

Let's build something beautiful. 🔗