// Package beads provides a wrapper for the bd (beads) CLI. package beads import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/steveyegge/gastown/internal/runtime" ) // Common errors // ZFC: Only define errors that don't require stderr parsing for decisions. // ErrNotARepo and ErrSyncConflict were removed - agents should handle these directly. var ( ErrNotInstalled = errors.New("bd not installed: run 'pip install beads-cli' or see https://github.com/anthropics/beads") ErrNotFound = errors.New("issue not found") ) // Issue represents a beads issue. type Issue struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` Priority int `json:"priority"` Type string `json:"issue_type"` CreatedAt string `json:"created_at"` CreatedBy string `json:"created_by,omitempty"` UpdatedAt string `json:"updated_at"` ClosedAt string `json:"closed_at,omitempty"` Parent string `json:"parent,omitempty"` Assignee string `json:"assignee,omitempty"` Children []string `json:"children,omitempty"` DependsOn []string `json:"depends_on,omitempty"` Blocks []string `json:"blocks,omitempty"` BlockedBy []string `json:"blocked_by,omitempty"` Labels []string `json:"labels,omitempty"` // Agent bead slots (type=agent only) HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared) AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck) // Counts from list output DependencyCount int `json:"dependency_count,omitempty"` DependentCount int `json:"dependent_count,omitempty"` BlockedByCount int `json:"blocked_by_count,omitempty"` // Detailed dependency info from show output Dependencies []IssueDep `json:"dependencies,omitempty"` Dependents []IssueDep `json:"dependents,omitempty"` } // IssueDep represents a dependency or dependent issue with its relation. type IssueDep struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Priority int `json:"priority"` Type string `json:"issue_type"` DependencyType string `json:"dependency_type,omitempty"` } // ListOptions specifies filters for listing issues. type ListOptions struct { Status string // "open", "closed", "all" Type string // Deprecated: use Label instead. "task", "bug", "feature", "epic" Label string // Label filter (e.g., "gt:agent", "gt:merge-request") Priority int // 0-4, -1 for no filter Parent string // filter by parent ID Assignee string // filter by assignee (e.g., "gastown/Toast") NoAssignee bool // filter for issues with no assignee } // CreateOptions specifies options for creating an issue. type CreateOptions struct { Title string Type string // "task", "bug", "feature", "epic" Priority int // 0-4 Description string Parent string Actor string // Who is creating this issue (populates created_by) } // UpdateOptions specifies options for updating an issue. type UpdateOptions struct { Title *string Status *string Priority *int Description *string Assignee *string AddLabels []string // Labels to add RemoveLabels []string // Labels to remove SetLabels []string // Labels to set (replaces all existing) } // SyncStatus represents the sync status of the beads repository. type SyncStatus struct { Branch string Ahead int Behind int Conflicts []string } // Beads wraps bd CLI operations for a working directory. type Beads struct { workDir string beadsDir string // Optional BEADS_DIR override for cross-database access } // New creates a new Beads wrapper for the given directory. func New(workDir string) *Beads { return &Beads{workDir: workDir} } // NewWithBeadsDir creates a Beads wrapper with an explicit BEADS_DIR. // This is needed when running from a polecat worktree but accessing town-level beads. func NewWithBeadsDir(workDir, beadsDir string) *Beads { return &Beads{workDir: workDir, beadsDir: beadsDir} } // run executes a bd command and returns stdout. func (b *Beads) run(args ...string) ([]byte, error) { // Use --no-daemon for faster read operations (avoids daemon IPC overhead) // The daemon is primarily useful for write coalescing, not reads fullArgs := append([]string{"--no-daemon"}, args...) cmd := exec.Command("bd", fullArgs...) //nolint:gosec // G204: bd is a trusted internal tool cmd.Dir = b.workDir // Set BEADS_DIR if specified (enables cross-database access) if b.beadsDir != "" { cmd.Env = append(os.Environ(), "BEADS_DIR="+b.beadsDir) } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { return nil, b.wrapError(err, stderr.String(), args) } return stdout.Bytes(), nil } // Run executes a bd command and returns stdout. // This is a public wrapper around the internal run method for cases where // callers need to run arbitrary bd commands. func (b *Beads) Run(args ...string) ([]byte, error) { return b.run(args...) } // wrapError wraps bd errors with context. // ZFC: Avoid parsing stderr to make decisions. Transport errors to agents instead. // Exception: ErrNotInstalled (exec.ErrNotFound) and ErrNotFound (issue lookup) are // acceptable as they enable basic error handling without decision-making. func (b *Beads) wrapError(err error, stderr string, args []string) error { stderr = strings.TrimSpace(stderr) // Check for bd not installed if execErr, ok := err.(*exec.Error); ok && errors.Is(execErr.Err, exec.ErrNotFound) { return ErrNotInstalled } // ErrNotFound is widely used for issue lookups - acceptable exception if strings.Contains(stderr, "not found") || strings.Contains(stderr, "Issue not found") { return ErrNotFound } if stderr != "" { return fmt.Errorf("bd %s: %s", strings.Join(args, " "), stderr) } return fmt.Errorf("bd %s: %w", strings.Join(args, " "), err) } // List returns issues matching the given options. func (b *Beads) List(opts ListOptions) ([]*Issue, error) { args := []string{"list", "--json"} if opts.Status != "" { args = append(args, "--status="+opts.Status) } // Prefer Label over Type (Type is deprecated) if opts.Label != "" { args = append(args, "--label="+opts.Label) } else if opts.Type != "" { // Deprecated: convert type to label for backward compatibility args = append(args, "--label=gt:"+opts.Type) } if opts.Priority >= 0 { args = append(args, fmt.Sprintf("--priority=%d", opts.Priority)) } if opts.Parent != "" { args = append(args, "--parent="+opts.Parent) } if opts.Assignee != "" { args = append(args, "--assignee="+opts.Assignee) } if opts.NoAssignee { args = append(args, "--no-assignee") } out, err := b.run(args...) if err != nil { return nil, err } var issues []*Issue if err := json.Unmarshal(out, &issues); err != nil { return nil, fmt.Errorf("parsing bd list output: %w", err) } return issues, nil } // ListByAssignee returns all issues assigned to a specific assignee. // The assignee is typically in the format "rig/polecatName" (e.g., "gastown/Toast"). func (b *Beads) ListByAssignee(assignee string) ([]*Issue, error) { return b.List(ListOptions{ Status: "all", // Include both open and closed for state derivation Assignee: assignee, Priority: -1, // No priority filter }) } // GetAssignedIssue returns the first open issue assigned to the given assignee. // Returns nil if no open issue is assigned. func (b *Beads) GetAssignedIssue(assignee string) (*Issue, error) { issues, err := b.List(ListOptions{ Status: "open", Assignee: assignee, Priority: -1, }) if err != nil { return nil, err } // Also check in_progress status explicitly if len(issues) == 0 { issues, err = b.List(ListOptions{ Status: "in_progress", Assignee: assignee, Priority: -1, }) if err != nil { return nil, err } } if len(issues) == 0 { return nil, nil } return issues[0], nil } // Ready returns issues that are ready to work (not blocked). func (b *Beads) Ready() ([]*Issue, error) { out, err := b.run("ready", "--json") if err != nil { return nil, err } var issues []*Issue if err := json.Unmarshal(out, &issues); err != nil { return nil, fmt.Errorf("parsing bd ready output: %w", err) } return issues, nil } // ReadyWithType returns ready issues filtered by label. // Uses bd ready --label flag for server-side filtering. // The issueType is converted to a gt: label (e.g., "molecule" -> "gt:molecule"). func (b *Beads) ReadyWithType(issueType string) ([]*Issue, error) { out, err := b.run("ready", "--json", "--label", "gt:"+issueType, "-n", "100") if err != nil { return nil, err } var issues []*Issue if err := json.Unmarshal(out, &issues); err != nil { return nil, fmt.Errorf("parsing bd ready output: %w", err) } return issues, nil } // Show returns detailed information about an issue. func (b *Beads) Show(id string) (*Issue, error) { out, err := b.run("show", id, "--json") if err != nil { return nil, err } // bd show --json returns an array with one element var issues []*Issue if err := json.Unmarshal(out, &issues); err != nil { return nil, fmt.Errorf("parsing bd show output: %w", err) } if len(issues) == 0 { return nil, ErrNotFound } return issues[0], nil } // ShowMultiple fetches multiple issues by ID in a single bd call. // Returns a map of ID to Issue. Missing IDs are not included in the map. func (b *Beads) ShowMultiple(ids []string) (map[string]*Issue, error) { if len(ids) == 0 { return make(map[string]*Issue), nil } // bd show supports multiple IDs args := append([]string{"show", "--json"}, ids...) out, err := b.run(args...) if err != nil { // If bd fails, return empty map (some IDs might not exist) return make(map[string]*Issue), nil } var issues []*Issue if err := json.Unmarshal(out, &issues); err != nil { return nil, fmt.Errorf("parsing bd show output: %w", err) } result := make(map[string]*Issue, len(issues)) for _, issue := range issues { result[issue.ID] = issue } return result, nil } // Blocked returns issues that are blocked by dependencies. func (b *Beads) Blocked() ([]*Issue, error) { out, err := b.run("blocked", "--json") if err != nil { return nil, err } var issues []*Issue if err := json.Unmarshal(out, &issues); err != nil { return nil, fmt.Errorf("parsing bd blocked output: %w", err) } return issues, nil } // Create creates a new issue and returns it. // If opts.Actor is empty, it defaults to the BD_ACTOR environment variable. // This ensures created_by is populated for issue provenance tracking. func (b *Beads) Create(opts CreateOptions) (*Issue, error) { args := []string{"create", "--json"} if opts.Title != "" { args = append(args, "--title="+opts.Title) } // Type is deprecated: convert to gt: label if opts.Type != "" { args = append(args, "--labels=gt:"+opts.Type) } if opts.Priority >= 0 { args = append(args, fmt.Sprintf("--priority=%d", opts.Priority)) } if opts.Description != "" { args = append(args, "--description="+opts.Description) } if opts.Parent != "" { args = append(args, "--parent="+opts.Parent) } // Default Actor from BD_ACTOR env var if not specified actor := opts.Actor if actor == "" { actor = os.Getenv("BD_ACTOR") } if actor != "" { args = append(args, "--actor="+actor) } out, err := b.run(args...) if err != nil { return nil, err } var issue Issue if err := json.Unmarshal(out, &issue); err != nil { return nil, fmt.Errorf("parsing bd create output: %w", err) } return &issue, nil } // CreateWithID creates an issue with a specific ID. // This is useful for agent beads, role beads, and other beads that need // deterministic IDs rather than auto-generated ones. func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) { args := []string{"create", "--json", "--id=" + id} if opts.Title != "" { args = append(args, "--title="+opts.Title) } // Type is deprecated: convert to gt: label if opts.Type != "" { args = append(args, "--labels=gt:"+opts.Type) } if opts.Priority >= 0 { args = append(args, fmt.Sprintf("--priority=%d", opts.Priority)) } if opts.Description != "" { args = append(args, "--description="+opts.Description) } if opts.Parent != "" { args = append(args, "--parent="+opts.Parent) } // Default Actor from BD_ACTOR env var if not specified actor := opts.Actor if actor == "" { actor = os.Getenv("BD_ACTOR") } if actor != "" { args = append(args, "--actor="+actor) } out, err := b.run(args...) if err != nil { return nil, err } var issue Issue if err := json.Unmarshal(out, &issue); err != nil { return nil, fmt.Errorf("parsing bd create output: %w", err) } return &issue, nil } // Update updates an existing issue. func (b *Beads) Update(id string, opts UpdateOptions) error { args := []string{"update", id} if opts.Title != nil { args = append(args, "--title="+*opts.Title) } if opts.Status != nil { args = append(args, "--status="+*opts.Status) } if opts.Priority != nil { args = append(args, fmt.Sprintf("--priority=%d", *opts.Priority)) } if opts.Description != nil { args = append(args, "--description="+*opts.Description) } if opts.Assignee != nil { args = append(args, "--assignee="+*opts.Assignee) } // Label operations: set-labels replaces all, otherwise use add/remove if len(opts.SetLabels) > 0 { for _, label := range opts.SetLabels { args = append(args, "--set-labels="+label) } } else { for _, label := range opts.AddLabels { args = append(args, "--add-label="+label) } for _, label := range opts.RemoveLabels { args = append(args, "--remove-label="+label) } } _, err := b.run(args...) return err } // Close closes one or more issues. // If a runtime session ID is set in the environment, it is passed to bd close // for work attribution tracking (see decision 009-session-events-architecture.md). func (b *Beads) Close(ids ...string) error { if len(ids) == 0 { return nil } args := append([]string{"close"}, ids...) // Pass session ID for work attribution if available if sessionID := runtime.SessionIDFromEnv(); sessionID != "" { args = append(args, "--session="+sessionID) } _, err := b.run(args...) return err } // CloseWithReason closes one or more issues with a reason. // If a runtime session ID is set in the environment, it is passed to bd close // for work attribution tracking (see decision 009-session-events-architecture.md). func (b *Beads) CloseWithReason(reason string, ids ...string) error { if len(ids) == 0 { return nil } args := append([]string{"close"}, ids...) args = append(args, "--reason="+reason) // Pass session ID for work attribution if available if sessionID := runtime.SessionIDFromEnv(); sessionID != "" { args = append(args, "--session="+sessionID) } _, err := b.run(args...) return err } // Release moves an in_progress issue back to open status. // This is used to recover stuck steps when a worker dies mid-task. // It clears the assignee so the step can be claimed by another worker. func (b *Beads) Release(id string) error { return b.ReleaseWithReason(id, "") } // ReleaseWithReason moves an in_progress issue back to open status with a reason. // The reason is added as a note to the issue for tracking purposes. func (b *Beads) ReleaseWithReason(id, reason string) error { args := []string{"update", id, "--status=open", "--assignee="} // Add reason as a note if provided if reason != "" { args = append(args, "--notes=Released: "+reason) } _, err := b.run(args...) return err } // AddDependency adds a dependency: issue depends on dependsOn. func (b *Beads) AddDependency(issue, dependsOn string) error { _, err := b.run("dep", "add", issue, dependsOn) return err } // RemoveDependency removes a dependency. func (b *Beads) RemoveDependency(issue, dependsOn string) error { _, err := b.run("dep", "remove", issue, dependsOn) return err } // Sync syncs beads with remote. func (b *Beads) Sync() error { _, err := b.run("sync") return err } // SyncFromMain syncs beads updates from main branch. func (b *Beads) SyncFromMain() error { _, err := b.run("sync", "--from-main") return err } // GetSyncStatus returns the sync status without performing a sync. func (b *Beads) GetSyncStatus() (*SyncStatus, error) { out, err := b.run("sync", "--status", "--json") if err != nil { // If sync branch doesn't exist, return empty status if strings.Contains(err.Error(), "does not exist") { return &SyncStatus{}, nil } return nil, err } var status SyncStatus if err := json.Unmarshal(out, &status); err != nil { return nil, fmt.Errorf("parsing bd sync status output: %w", err) } return &status, nil } // Stats returns repository statistics. func (b *Beads) Stats() (string, error) { out, err := b.run("stats") if err != nil { return "", err } return string(out), nil } // IsBeadsRepo checks if the working directory is a beads repository. // ZFC: Check file existence directly instead of parsing bd errors. func (b *Beads) IsBeadsRepo() bool { beadsDir := ResolveBeadsDir(b.workDir) info, err := os.Stat(beadsDir) return err == nil && info.IsDir() } // primeContent is the Gas Town PRIME.md content that provides essential context // for crew workers. This is the fallback if the SessionStart hook fails. const primeContent = `# Gas Town Worker Context > **Context Recovery**: Run ` + "`gt prime`" + ` for full context after compaction or new session. ## The Propulsion Principle (GUPP) **If you find work on your hook, YOU RUN IT.** No confirmation. No waiting. No announcements. The hook having work IS the assignment. This is physics, not politeness. Gas Town is a steam engine - you are a piston. **Failure mode we're preventing:** - Agent starts with work on hook - Agent announces itself and waits for human to say "ok go" - Human is AFK / trusting the engine to run - Work sits idle. The whole system stalls. ## Startup Protocol 1. Check your hook: ` + "`gt mol status`" + ` 2. If work is hooked → EXECUTE (no announcement, no waiting) 3. If hook empty → Check mail: ` + "`gt mail inbox`" + ` 4. Still nothing? Wait for user instructions ## Key Commands - ` + "`gt prime`" + ` - Get full role context (run after compaction) - ` + "`gt mol status`" + ` - Check your hooked work - ` + "`gt mail inbox`" + ` - Check for messages - ` + "`bd ready`" + ` - Find available work (no blockers) - ` + "`bd sync`" + ` - Sync beads changes ## Session Close Protocol Before saying "done": 1. git status (check what changed) 2. git add (stage code changes) 3. bd sync (commit beads changes) 4. git commit -m "..." (commit code) 5. bd sync (commit any new beads changes) 6. git push (push to remote) **Work is not done until pushed.** ` // ProvisionPrimeMD writes the Gas Town PRIME.md file to the specified beads directory. // This provides essential Gas Town context (GUPP, startup protocol) as a fallback // if the SessionStart hook fails. The PRIME.md is read by bd prime. // // The beadsDir should be the actual beads directory (after following any redirect). // Returns nil if PRIME.md already exists (idempotent). func ProvisionPrimeMD(beadsDir string) error { primePath := filepath.Join(beadsDir, "PRIME.md") // Check if already exists - don't overwrite customizations if _, err := os.Stat(primePath); err == nil { return nil // Already exists, don't overwrite } // Create .beads directory if it doesn't exist if err := os.MkdirAll(beadsDir, 0755); err != nil { return fmt.Errorf("creating beads dir: %w", err) } // Write PRIME.md if err := os.WriteFile(primePath, []byte(primeContent), 0644); err != nil { return fmt.Errorf("writing PRIME.md: %w", err) } return nil } // ProvisionPrimeMDForWorktree provisions PRIME.md for a worktree by following its redirect. // This is the main entry point for crew/polecat provisioning. func ProvisionPrimeMDForWorktree(worktreePath string) error { // Resolve the beads directory (follows redirect chain) beadsDir := ResolveBeadsDir(worktreePath) // Provision PRIME.md in the target directory return ProvisionPrimeMD(beadsDir) }