Replace bd subprocess calls in gt commands with daemon RPC when available. Each subprocess call has ~40ms overhead for Go binary startup, so using the daemon's Unix socket protocol significantly reduces latency. Changes: - Add RPC client to beads package (beads_rpc.go) - Modify List/Show/Update/Close methods to try RPC first, fall back to subprocess - Replace runBdPrime() with direct content output (avoids bd subprocess) - Replace checkPendingEscalations() to use beads.List() with RPC - Replace hook.go bd subprocess calls with beads package methods The RPC client: - Connects to daemon via Unix socket at .beads/bd.sock - Uses JSON-based request/response protocol (same as bd daemon) - Falls back gracefully to subprocess if daemon unavailable - Lazy-initializes connection on first use Performance improvement targets (from bd-2zd.2): - gt prime < 100ms (was 5.8s with subprocess chain) - gt hook < 100ms (was ~323ms) Closes: bd-2zd.2
892 lines
27 KiB
Go
892 lines
27 KiB
Go
// 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
|
|
AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck)
|
|
// Note: role_bead field removed - role definitions are now config-based
|
|
|
|
// 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)
|
|
Ephemeral bool // Create as ephemeral (wisp) - not exported to JSONL
|
|
}
|
|
|
|
// 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
|
|
isolated bool // If true, suppress inherited beads env vars (for test isolation)
|
|
|
|
// Lazy-cached town root for routing resolution.
|
|
// Populated on first call to getTownRoot() to avoid filesystem walk on every operation.
|
|
townRoot string
|
|
searchedRoot bool
|
|
|
|
// RPC client for daemon communication (lazy-initialized).
|
|
// When available, RPC is preferred over subprocess for performance.
|
|
rpcClient *rpcClient
|
|
rpcChecked bool
|
|
rpcAvailable bool
|
|
}
|
|
|
|
// New creates a new Beads wrapper for the given directory.
|
|
func New(workDir string) *Beads {
|
|
return &Beads{workDir: workDir}
|
|
}
|
|
|
|
// NewIsolated creates a Beads wrapper for test isolation.
|
|
// This suppresses inherited beads env vars (BD_ACTOR, BEADS_DB) to prevent
|
|
// tests from accidentally routing to production databases.
|
|
func NewIsolated(workDir string) *Beads {
|
|
return &Beads{workDir: workDir, isolated: true}
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
// getActor returns the BD_ACTOR value for this context.
|
|
// Returns empty string when in isolated mode (tests) to prevent
|
|
// inherited actors from routing to production databases.
|
|
func (b *Beads) getActor() string {
|
|
if b.isolated {
|
|
return ""
|
|
}
|
|
return os.Getenv("BD_ACTOR")
|
|
}
|
|
|
|
// getTownRoot returns the Gas Town root directory, using lazy caching.
|
|
// The town root is found by walking up from workDir looking for mayor/town.json.
|
|
// Returns empty string if not in a Gas Town project.
|
|
func (b *Beads) getTownRoot() string {
|
|
if !b.searchedRoot {
|
|
b.townRoot = FindTownRoot(b.workDir)
|
|
b.searchedRoot = true
|
|
}
|
|
return b.townRoot
|
|
}
|
|
|
|
// getResolvedBeadsDir returns the beads directory this wrapper is operating on.
|
|
// This follows any redirects and returns the actual beads directory path.
|
|
func (b *Beads) getResolvedBeadsDir() string {
|
|
if b.beadsDir != "" {
|
|
return b.beadsDir
|
|
}
|
|
return ResolveBeadsDir(b.workDir)
|
|
}
|
|
|
|
// Init initializes a new beads database in the working directory.
|
|
// This uses the same environment isolation as other commands.
|
|
func (b *Beads) Init(prefix string) error {
|
|
_, err := b.run("init", "--prefix", prefix, "--quiet")
|
|
return err
|
|
}
|
|
|
|
// 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.
|
|
// Use --allow-stale to prevent failures when db is out of sync with JSONL
|
|
// (e.g., after daemon is killed during shutdown before syncing).
|
|
fullArgs := append([]string{"--no-daemon", "--allow-stale"}, args...)
|
|
|
|
// Always explicitly set BEADS_DIR to prevent inherited env vars from
|
|
// causing prefix mismatches. Use explicit beadsDir if set, otherwise
|
|
// resolve from working directory.
|
|
beadsDir := b.beadsDir
|
|
if beadsDir == "" {
|
|
beadsDir = ResolveBeadsDir(b.workDir)
|
|
}
|
|
|
|
// In isolated mode, use --db flag to force specific database path
|
|
// This bypasses bd's routing logic that can redirect to .beads-planning
|
|
// Skip --db for init command since it creates the database
|
|
isInit := len(args) > 0 && args[0] == "init"
|
|
if b.isolated && !isInit {
|
|
beadsDB := filepath.Join(beadsDir, "beads.db")
|
|
fullArgs = append([]string{"--db", beadsDB}, fullArgs...)
|
|
}
|
|
|
|
cmd := exec.Command("bd", fullArgs...) //nolint:gosec // G204: bd is a trusted internal tool
|
|
cmd.Dir = b.workDir
|
|
|
|
// Build environment: filter beads env vars when in isolated mode (tests)
|
|
// to prevent routing to production databases.
|
|
var env []string
|
|
if b.isolated {
|
|
env = filterBeadsEnv(os.Environ())
|
|
} else {
|
|
env = os.Environ()
|
|
}
|
|
cmd.Env = append(env, "BEADS_DIR="+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)
|
|
}
|
|
|
|
// Handle bd --no-daemon exit code 0 bug: when issue not found,
|
|
// --no-daemon exits 0 but writes error to stderr with empty stdout.
|
|
// Detect this case and treat as error to avoid JSON parse failures.
|
|
if stdout.Len() == 0 && stderr.Len() > 0 {
|
|
return nil, b.wrapError(fmt.Errorf("command produced no output"), 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
|
|
// Match various "not found" error patterns from bd
|
|
if strings.Contains(stderr, "not found") || strings.Contains(stderr, "Issue not found") ||
|
|
strings.Contains(stderr, "no issue 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)
|
|
}
|
|
|
|
// filterBeadsEnv removes beads-related environment variables from the given
|
|
// environment slice. This ensures test isolation by preventing inherited
|
|
// BD_ACTOR, BEADS_DB, GT_ROOT, HOME etc. from routing commands to production databases.
|
|
func filterBeadsEnv(environ []string) []string {
|
|
filtered := make([]string, 0, len(environ))
|
|
for _, env := range environ {
|
|
// Skip beads-related env vars that could interfere with test isolation
|
|
// BD_ACTOR, BEADS_* - direct beads config
|
|
// GT_ROOT - causes bd to find global routes file
|
|
// HOME - causes bd to find ~/.beads-planning routing
|
|
if strings.HasPrefix(env, "BD_ACTOR=") ||
|
|
strings.HasPrefix(env, "BEADS_") ||
|
|
strings.HasPrefix(env, "GT_ROOT=") ||
|
|
strings.HasPrefix(env, "HOME=") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, env)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// List returns issues matching the given options.
|
|
// Uses daemon RPC when available for better performance (~40ms faster).
|
|
func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
|
|
// Try RPC first (faster when daemon is running)
|
|
if issues, err := b.listViaRPC(opts); err == nil {
|
|
return issues, nil
|
|
}
|
|
|
|
// Fall back to subprocess
|
|
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:<type> 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.
|
|
// Uses daemon RPC when available for better performance (~40ms faster).
|
|
func (b *Beads) Show(id string) (*Issue, error) {
|
|
// Try RPC first (faster when daemon is running)
|
|
if issue, err := b.showViaRPC(id); err == nil {
|
|
return issue, nil
|
|
}
|
|
|
|
// Fall back to subprocess
|
|
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:<type> 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)
|
|
}
|
|
if opts.Ephemeral {
|
|
args = append(args, "--ephemeral")
|
|
}
|
|
// Default Actor from BD_ACTOR env var if not specified
|
|
// Uses getActor() to respect isolated mode (tests)
|
|
actor := opts.Actor
|
|
if actor == "" {
|
|
actor = b.getActor()
|
|
}
|
|
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 NeedsForceForID(id) {
|
|
args = append(args, "--force")
|
|
}
|
|
|
|
if opts.Title != "" {
|
|
args = append(args, "--title="+opts.Title)
|
|
}
|
|
// Type is deprecated: convert to gt:<type> 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
|
|
// Uses getActor() to respect isolated mode (tests)
|
|
actor := opts.Actor
|
|
if actor == "" {
|
|
actor = b.getActor()
|
|
}
|
|
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.
|
|
// Uses daemon RPC when available for better performance (~40ms faster).
|
|
func (b *Beads) Update(id string, opts UpdateOptions) error {
|
|
// Try RPC first (faster when daemon is running)
|
|
if err := b.updateViaRPC(id, opts); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Fall back to subprocess
|
|
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).
|
|
// Uses daemon RPC when available for better performance (~40ms faster per call).
|
|
func (b *Beads) Close(ids ...string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
sessionID := runtime.SessionIDFromEnv()
|
|
|
|
// Try RPC for single-issue closes (faster when daemon is running)
|
|
if len(ids) == 1 {
|
|
if err := b.closeViaRPC(ids[0], "", sessionID, false); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Fall back to subprocess
|
|
args := append([]string{"close"}, ids...)
|
|
|
|
// Pass session ID for work attribution if available
|
|
if 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).
|
|
// Uses daemon RPC when available for better performance (~40ms faster per call).
|
|
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
sessionID := runtime.SessionIDFromEnv()
|
|
|
|
// Try RPC for single-issue closes (faster when daemon is running)
|
|
if len(ids) == 1 {
|
|
if err := b.closeViaRPC(ids[0], reason, sessionID, false); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Fall back to subprocess
|
|
args := append([]string{"close"}, ids...)
|
|
args = append(args, "--reason="+reason)
|
|
|
|
// Pass session ID for work attribution if available
|
|
if sessionID != "" {
|
|
args = append(args, "--session="+sessionID)
|
|
}
|
|
|
|
_, err := b.run(args...)
|
|
return err
|
|
}
|
|
|
|
// CloseForced closes an issue with force flag and optional reason.
|
|
// The force flag bypasses blockers and other validation checks.
|
|
// Uses daemon RPC when available for better performance (~40ms faster).
|
|
func (b *Beads) CloseForced(id, reason string) error {
|
|
sessionID := runtime.SessionIDFromEnv()
|
|
|
|
// Try RPC first (faster when daemon is running)
|
|
if err := b.closeViaRPC(id, reason, sessionID, true); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Fall back to subprocess
|
|
args := []string{"close", id, "--force"}
|
|
if reason != "" {
|
|
args = append(args, "--reason="+reason)
|
|
}
|
|
if 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 tree <id>`" + ` - View bead ancestry, siblings, and dependencies
|
|
- ` + "`bd sync`" + ` - Sync beads changes
|
|
|
|
## Session Close Protocol
|
|
|
|
Before signaling completion:
|
|
1. git status (check what changed)
|
|
2. git add <files> (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)
|
|
7. ` + "`gt done`" + ` (submit to merge queue and exit)
|
|
|
|
**Polecats MUST call ` + "`gt done`" + ` - this submits work and exits the session.**
|
|
`
|
|
|
|
// 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)
|
|
}
|
|
|
|
// GetPrimeContent returns the beads workflow context content.
|
|
// It checks for a custom PRIME.md file first, otherwise returns the default.
|
|
// This eliminates the need to spawn a bd subprocess for gt prime.
|
|
func GetPrimeContent(workDir string) string {
|
|
beadsDir := ResolveBeadsDir(workDir)
|
|
primePath := filepath.Join(beadsDir, "PRIME.md")
|
|
|
|
// Check for custom PRIME.md
|
|
if content, err := os.ReadFile(primePath); err == nil {
|
|
return strings.TrimSpace(string(content))
|
|
}
|
|
|
|
// Return default content
|
|
return strings.TrimSpace(primeContent)
|
|
}
|