Adds support for alternative AI runtime backends (Codex, OpenCode) alongside the default Claude backend through a runtime abstraction layer. - internal/runtime/runtime.go - Runtime-agnostic helper functions - Extended RuntimeConfig with provider-specific settings - internal/opencode/ for OpenCode plugin support - Updated session managers to use runtime abstraction - Removed unused ensureXxxSession functions - Fixed daemon.go indentation, updated terminology to runtime Backward compatible: Claude remains default runtime. Co-Authored-By: Ben Kraus <ben@cinematicsoftware.com> Co-Authored-By: Cameron Palmer <cameronmpalmer@users.noreply.github.com>
1958 lines
60 KiB
Go
1958 lines
60 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
|
|
var (
|
|
ErrNotInstalled = errors.New("bd not installed: run 'pip install beads-cli' or see https://github.com/anthropics/beads")
|
|
ErrNotARepo = errors.New("not a beads repository (no .beads directory found)")
|
|
ErrSyncConflict = errors.New("beads sync conflict")
|
|
ErrNotFound = errors.New("issue not found")
|
|
)
|
|
|
|
// ResolveBeadsDir returns the actual beads directory, following any redirect.
|
|
// If workDir/.beads/redirect exists, it reads the redirect path and resolves it
|
|
// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads.
|
|
//
|
|
// This is essential for crew workers and polecats that use shared beads via redirect.
|
|
// The redirect file contains a relative path like "../../mayor/rig/.beads".
|
|
//
|
|
// Example: if we're at crew/max/ and .beads/redirect contains "../../mayor/rig/.beads",
|
|
// the redirect is resolved from crew/max/ (not crew/max/.beads/), giving us
|
|
// mayor/rig/.beads at the rig root level.
|
|
//
|
|
// Circular redirect detection: If the resolved path equals the original beads directory,
|
|
// this indicates an errant redirect file that should be removed. The function logs a
|
|
// warning and returns the original beads directory.
|
|
func ResolveBeadsDir(workDir string) string {
|
|
beadsDir := filepath.Join(workDir, ".beads")
|
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
|
|
|
// Check for redirect file
|
|
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
|
if err != nil {
|
|
// No redirect, use local .beads
|
|
return beadsDir
|
|
}
|
|
|
|
// Read and clean the redirect path
|
|
redirectTarget := strings.TrimSpace(string(data))
|
|
if redirectTarget == "" {
|
|
return beadsDir
|
|
}
|
|
|
|
// Resolve relative to workDir (the redirect is written from the perspective
|
|
// of being inside workDir, not inside workDir/.beads)
|
|
// e.g., redirect contains "../../mayor/rig/.beads"
|
|
// from crew/max/, this resolves to mayor/rig/.beads
|
|
resolved := filepath.Join(workDir, redirectTarget)
|
|
|
|
// Clean the path to resolve .. components
|
|
resolved = filepath.Clean(resolved)
|
|
|
|
// Detect circular redirects: if resolved path equals original beads dir,
|
|
// this is an errant redirect file (e.g., redirect in mayor/rig/.beads pointing to itself)
|
|
if resolved == beadsDir {
|
|
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s (points to itself), ignoring redirect\n", redirectPath)
|
|
// Remove the errant redirect file to prevent future warnings
|
|
if err := os.Remove(redirectPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: could not remove errant redirect file: %v\n", err)
|
|
}
|
|
return beadsDir
|
|
}
|
|
|
|
// Follow redirect chains (e.g., crew/.beads -> rig/.beads -> mayor/rig/.beads)
|
|
// This is intentional for the rig-level redirect architecture.
|
|
// Limit depth to prevent infinite loops from misconfigured redirects.
|
|
return resolveBeadsDirWithDepth(resolved, 3)
|
|
}
|
|
|
|
// resolveBeadsDirWithDepth follows redirect chains with a depth limit.
|
|
func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string {
|
|
if maxDepth <= 0 {
|
|
fmt.Fprintf(os.Stderr, "Warning: redirect chain too deep at %s, stopping\n", beadsDir)
|
|
return beadsDir
|
|
}
|
|
|
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
|
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
|
if err != nil {
|
|
// No redirect, this is the final destination
|
|
return beadsDir
|
|
}
|
|
|
|
redirectTarget := strings.TrimSpace(string(data))
|
|
if redirectTarget == "" {
|
|
return beadsDir
|
|
}
|
|
|
|
// Resolve relative to parent of beadsDir (the workDir)
|
|
workDir := filepath.Dir(beadsDir)
|
|
resolved := filepath.Clean(filepath.Join(workDir, redirectTarget))
|
|
|
|
// Detect circular redirect
|
|
if resolved == beadsDir {
|
|
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s, stopping\n", redirectPath)
|
|
return beadsDir
|
|
}
|
|
|
|
// Recursively follow
|
|
return resolveBeadsDirWithDepth(resolved, maxDepth-1)
|
|
}
|
|
|
|
// cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory
|
|
// while preserving tracked files (formulas/, README.md, config.yaml, .gitignore).
|
|
// This is safe to call even if the directory doesn't exist.
|
|
func cleanBeadsRuntimeFiles(beadsDir string) error {
|
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
|
return nil // Nothing to clean
|
|
}
|
|
|
|
// Runtime files/patterns that are gitignored and safe to remove
|
|
runtimePatterns := []string{
|
|
// SQLite databases
|
|
"*.db", "*.db-*", "*.db?*",
|
|
// Daemon runtime
|
|
"daemon.lock", "daemon.log", "daemon.pid", "bd.sock",
|
|
// Sync state
|
|
"sync-state.json", "last-touched", "metadata.json",
|
|
// Version tracking
|
|
".local_version",
|
|
// Redirect file (we're about to recreate it)
|
|
"redirect",
|
|
// Merge artifacts
|
|
"beads.base.*", "beads.left.*", "beads.right.*",
|
|
// JSONL files (tracked but will be redirected, safe to remove in worktrees)
|
|
"issues.jsonl", "interactions.jsonl",
|
|
// Runtime directories
|
|
"mq",
|
|
}
|
|
|
|
var firstErr error
|
|
for _, pattern := range runtimePatterns {
|
|
matches, err := filepath.Glob(filepath.Join(beadsDir, pattern))
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
for _, match := range matches {
|
|
if err := os.RemoveAll(match); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
|
|
return firstErr
|
|
}
|
|
|
|
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
|
// This is used by crew, polecats, and refinery worktrees to share the rig's beads database.
|
|
//
|
|
// Parameters:
|
|
// - townRoot: the town root directory (e.g., ~/gt)
|
|
// - worktreePath: the worktree directory (e.g., <rig>/crew/<name> or <rig>/refinery/rig)
|
|
//
|
|
// The function:
|
|
// 1. Computes the relative path from worktree to rig-level .beads
|
|
// 2. Cleans up runtime files (preserving tracked files like formulas/)
|
|
// 3. Creates the redirect file
|
|
//
|
|
// Safety: This function refuses to create redirects in the canonical beads location
|
|
// (mayor/rig) to prevent circular redirect chains.
|
|
func SetupRedirect(townRoot, worktreePath string) error {
|
|
// Get rig root from worktree path
|
|
// worktreePath = <town>/<rig>/crew/<name> or <town>/<rig>/refinery/rig etc.
|
|
relPath, err := filepath.Rel(townRoot, worktreePath)
|
|
if err != nil {
|
|
return fmt.Errorf("computing relative path: %w", err)
|
|
}
|
|
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
|
if len(parts) < 2 {
|
|
return fmt.Errorf("invalid worktree path: must be at least 2 levels deep from town root")
|
|
}
|
|
|
|
// Safety check: prevent creating redirect in canonical beads location (mayor/rig)
|
|
// This would create a circular redirect chain since rig/.beads redirects to mayor/rig/.beads
|
|
if len(parts) >= 2 && parts[1] == "mayor" {
|
|
return fmt.Errorf("cannot create redirect in canonical beads location (mayor/rig)")
|
|
}
|
|
|
|
rigRoot := filepath.Join(townRoot, parts[0])
|
|
rigBeadsPath := filepath.Join(rigRoot, ".beads")
|
|
mayorBeadsPath := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
|
|
|
// Check rig-level .beads first, fall back to mayor/rig/.beads (tracked beads architecture)
|
|
usesMayorFallback := false
|
|
if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) {
|
|
// No rig/.beads - check for mayor/rig/.beads (tracked beads architecture)
|
|
if _, err := os.Stat(mayorBeadsPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("no beads found at %s or %s", rigBeadsPath, mayorBeadsPath)
|
|
}
|
|
// Using mayor fallback - warn user to run bd doctor
|
|
fmt.Fprintf(os.Stderr, "Warning: rig .beads not found at %s, using %s\n", rigBeadsPath, mayorBeadsPath)
|
|
fmt.Fprintf(os.Stderr, " Run 'bd doctor' to fix rig beads configuration\n")
|
|
usesMayorFallback = true
|
|
}
|
|
|
|
// Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.)
|
|
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
|
if err := cleanBeadsRuntimeFiles(worktreeBeadsDir); err != nil {
|
|
return fmt.Errorf("cleaning runtime files: %w", err)
|
|
}
|
|
|
|
// Create .beads directory if it doesn't exist
|
|
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
|
return fmt.Errorf("creating .beads dir: %w", err)
|
|
}
|
|
|
|
// Compute relative path from worktree to rig root
|
|
// e.g., crew/<name> (depth 2) -> ../../.beads
|
|
// refinery/rig (depth 2) -> ../../.beads
|
|
depth := len(parts) - 1 // subtract 1 for rig name itself
|
|
upPath := strings.Repeat("../", depth)
|
|
|
|
var redirectPath string
|
|
if usesMayorFallback {
|
|
// Direct redirect to mayor/rig/.beads since rig/.beads doesn't exist
|
|
redirectPath = upPath + "mayor/rig/.beads"
|
|
} else {
|
|
redirectPath = upPath + ".beads"
|
|
|
|
// Check if rig-level beads has a redirect (tracked beads case).
|
|
// If so, redirect directly to the final destination to avoid chains.
|
|
// The bd CLI doesn't support redirect chains, so we must skip intermediate hops.
|
|
rigRedirectPath := filepath.Join(rigBeadsPath, "redirect")
|
|
if data, err := os.ReadFile(rigRedirectPath); err == nil {
|
|
rigRedirectTarget := strings.TrimSpace(string(data))
|
|
if rigRedirectTarget != "" {
|
|
// Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads).
|
|
// Redirect worktree directly to the final destination.
|
|
redirectPath = upPath + rigRedirectTarget
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create redirect file
|
|
redirectFile := filepath.Join(worktreeBeadsDir, "redirect")
|
|
if err := os.WriteFile(redirectFile, []byte(redirectPath+"\n"), 0644); err != nil {
|
|
return fmt.Errorf("creating redirect file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// Delegation represents a work delegation relationship between work units.
|
|
// Delegation links a parent work unit to a child work unit, tracking who
|
|
// delegated the work and to whom, along with any terms of the delegation.
|
|
// This enables work distribution with credit cascade - work flows down,
|
|
// validation and credit flow up.
|
|
type Delegation struct {
|
|
// Parent is the work unit ID that delegated the work
|
|
Parent string `json:"parent"`
|
|
|
|
// Child is the work unit ID that received the delegated work
|
|
Child string `json:"child"`
|
|
|
|
// DelegatedBy is the entity (hop:// URI or actor string) that delegated
|
|
DelegatedBy string `json:"delegated_by"`
|
|
|
|
// DelegatedTo is the entity (hop:// URI or actor string) receiving delegation
|
|
DelegatedTo string `json:"delegated_to"`
|
|
|
|
// Terms contains optional conditions of the delegation
|
|
Terms *DelegationTerms `json:"terms,omitempty"`
|
|
|
|
// CreatedAt is when the delegation was created
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
// DelegationTerms holds optional terms/conditions for a delegation.
|
|
type DelegationTerms struct {
|
|
// Portion describes what part of the parent work is delegated
|
|
Portion string `json:"portion,omitempty"`
|
|
|
|
// Deadline is the expected completion date
|
|
Deadline string `json:"deadline,omitempty"`
|
|
|
|
// AcceptanceCriteria describes what constitutes completion
|
|
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
|
|
|
// CreditShare is the percentage of credit that flows to the delegate (0-100)
|
|
CreditShare int `json:"credit_share,omitempty"`
|
|
}
|
|
|
|
// ListOptions specifies filters for listing issues.
|
|
type ListOptions struct {
|
|
Status string // "open", "closed", "all"
|
|
Type string // "task", "bug", "feature", "epic"
|
|
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.
|
|
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
|
|
}
|
|
|
|
// Detect specific error types from stderr
|
|
if strings.Contains(stderr, "not a beads repository") ||
|
|
strings.Contains(stderr, "No .beads directory") ||
|
|
strings.Contains(stderr, ".beads") && strings.Contains(stderr, "not found") {
|
|
return ErrNotARepo
|
|
}
|
|
if strings.Contains(stderr, "sync conflict") || strings.Contains(stderr, "CONFLICT") {
|
|
return ErrSyncConflict
|
|
}
|
|
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)
|
|
}
|
|
if opts.Type != "" {
|
|
args = append(args, "--type="+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 type.
|
|
// Uses bd ready --type flag for server-side filtering.
|
|
func (b *Beads) ReadyWithType(issueType string) ([]*Issue, error) {
|
|
out, err := b.run("ready", "--json", "--type", 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
|
|
}
|
|
|
|
// ListAgentBeads returns all agent beads in a single query.
|
|
// Returns a map of agent bead ID to Issue.
|
|
func (b *Beads) ListAgentBeads() (map[string]*Issue, error) {
|
|
out, err := b.run("list", "--type=agent", "--json")
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
if opts.Type != "" {
|
|
args = append(args, "--type="+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)
|
|
}
|
|
if opts.Type != "" {
|
|
args = append(args, "--type="+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
|
|
}
|
|
|
|
// AddDelegation creates a delegation relationship from parent to child work unit.
|
|
// The delegation tracks who delegated (delegatedBy) and who received (delegatedTo),
|
|
// along with optional terms. Delegations enable credit cascade - when child work
|
|
// is completed, credit flows up to the parent work unit and its delegator.
|
|
//
|
|
// Note: This is stored as metadata on the child issue until bd CLI has native
|
|
// delegation support. Once bd supports `bd delegate add`, this will be updated.
|
|
func (b *Beads) AddDelegation(d *Delegation) error {
|
|
if d.Parent == "" || d.Child == "" {
|
|
return fmt.Errorf("delegation requires both parent and child work unit IDs")
|
|
}
|
|
if d.DelegatedBy == "" || d.DelegatedTo == "" {
|
|
return fmt.Errorf("delegation requires both delegated_by and delegated_to entities")
|
|
}
|
|
|
|
// Store delegation as JSON in the child issue's delegated_from slot
|
|
delegationJSON, err := json.Marshal(d)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling delegation: %w", err)
|
|
}
|
|
|
|
// Set the delegated_from slot on the child issue
|
|
_, err = b.run("slot", "set", d.Child, "delegated_from", string(delegationJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("setting delegation slot: %w", err)
|
|
}
|
|
|
|
// Also add a dependency so child blocks parent (work must complete before parent can close)
|
|
if err := b.AddDependency(d.Parent, d.Child); err != nil {
|
|
// Log but don't fail - the delegation is still recorded
|
|
fmt.Printf("Warning: could not add blocking dependency for delegation: %v\n", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveDelegation removes a delegation relationship.
|
|
func (b *Beads) RemoveDelegation(parent, child string) error {
|
|
// Clear the delegated_from slot on the child
|
|
_, err := b.run("slot", "clear", child, "delegated_from")
|
|
if err != nil {
|
|
return fmt.Errorf("clearing delegation slot: %w", err)
|
|
}
|
|
|
|
// Also remove the blocking dependency
|
|
if err := b.RemoveDependency(parent, child); err != nil {
|
|
// Log but don't fail
|
|
fmt.Printf("Warning: could not remove blocking dependency: %v\n", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetDelegation retrieves the delegation information for a child work unit.
|
|
// Returns nil if the issue has no delegation.
|
|
func (b *Beads) GetDelegation(child string) (*Delegation, error) {
|
|
// Get the issue to read its slot
|
|
issue, err := b.Show(child)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting issue: %w", err)
|
|
}
|
|
|
|
// The slot would be in the description or a separate field
|
|
// For now, we'll need to parse from the bd slot get command
|
|
out, err := b.run("slot", "get", child, "delegated_from")
|
|
if err != nil {
|
|
// No delegation slot means no delegation
|
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no slot") {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("getting delegation slot: %w", err)
|
|
}
|
|
|
|
slotValue := strings.TrimSpace(string(out))
|
|
if slotValue == "" || slotValue == "null" {
|
|
return nil, nil
|
|
}
|
|
|
|
var delegation Delegation
|
|
if err := json.Unmarshal([]byte(slotValue), &delegation); err != nil {
|
|
return nil, fmt.Errorf("parsing delegation: %w", err)
|
|
}
|
|
|
|
// Keep issue reference for context (not used currently but available)
|
|
_ = issue
|
|
|
|
return &delegation, nil
|
|
}
|
|
|
|
// ListDelegationsFrom returns all delegations from a parent work unit.
|
|
// This searches for issues that have delegated_from pointing to the parent.
|
|
func (b *Beads) ListDelegationsFrom(parent string) ([]*Delegation, error) {
|
|
// List all issues that depend on this parent (delegated work blocks parent)
|
|
issues, err := b.List(ListOptions{Status: "all"})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing issues: %w", err)
|
|
}
|
|
|
|
var delegations []*Delegation
|
|
for _, issue := range issues {
|
|
d, err := b.GetDelegation(issue.ID)
|
|
if err != nil {
|
|
continue // Skip issues with errors
|
|
}
|
|
if d != nil && d.Parent == parent {
|
|
delegations = append(delegations, d)
|
|
}
|
|
}
|
|
|
|
return delegations, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SyncStatus returns the sync status without performing a sync.
|
|
func (b *Beads) SyncStatus() (*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.
|
|
func (b *Beads) IsBeadsRepo() bool {
|
|
_, err := b.run("list", "--limit=1")
|
|
return err == nil || !errors.Is(err, ErrNotARepo)
|
|
}
|
|
|
|
// AgentFields holds structured fields for agent beads.
|
|
// These are stored as "key: value" lines in the description.
|
|
type AgentFields struct {
|
|
RoleType string // polecat, witness, refinery, deacon, mayor
|
|
Rig string // Rig name (empty for global agents like mayor/deacon)
|
|
AgentState string // spawning, working, done, stuck
|
|
HookBead string // Currently pinned work bead ID
|
|
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
|
|
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
|
ActiveMR string // Currently active merge request bead ID (for traceability)
|
|
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
|
}
|
|
|
|
// Notification level constants
|
|
const (
|
|
NotifyVerbose = "verbose" // All notifications (mail, convoy events, etc.)
|
|
NotifyNormal = "normal" // Important events only (default)
|
|
NotifyMuted = "muted" // Silent/DND mode - batch for later
|
|
)
|
|
|
|
// FormatAgentDescription creates a description string from agent fields.
|
|
func FormatAgentDescription(title string, fields *AgentFields) string {
|
|
if fields == nil {
|
|
return title
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, title)
|
|
lines = append(lines, "")
|
|
lines = append(lines, fmt.Sprintf("role_type: %s", fields.RoleType))
|
|
|
|
if fields.Rig != "" {
|
|
lines = append(lines, fmt.Sprintf("rig: %s", fields.Rig))
|
|
} else {
|
|
lines = append(lines, "rig: null")
|
|
}
|
|
|
|
lines = append(lines, fmt.Sprintf("agent_state: %s", fields.AgentState))
|
|
|
|
if fields.HookBead != "" {
|
|
lines = append(lines, fmt.Sprintf("hook_bead: %s", fields.HookBead))
|
|
} else {
|
|
lines = append(lines, "hook_bead: null")
|
|
}
|
|
|
|
if fields.RoleBead != "" {
|
|
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
|
} else {
|
|
lines = append(lines, "role_bead: null")
|
|
}
|
|
|
|
if fields.CleanupStatus != "" {
|
|
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
|
} else {
|
|
lines = append(lines, "cleanup_status: null")
|
|
}
|
|
|
|
if fields.ActiveMR != "" {
|
|
lines = append(lines, fmt.Sprintf("active_mr: %s", fields.ActiveMR))
|
|
} else {
|
|
lines = append(lines, "active_mr: null")
|
|
}
|
|
|
|
if fields.NotificationLevel != "" {
|
|
lines = append(lines, fmt.Sprintf("notification_level: %s", fields.NotificationLevel))
|
|
} else {
|
|
lines = append(lines, "notification_level: null")
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// ParseAgentFields extracts agent fields from an issue's description.
|
|
func ParseAgentFields(description string) *AgentFields {
|
|
fields := &AgentFields{}
|
|
|
|
for _, line := range strings.Split(description, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
colonIdx := strings.Index(line, ":")
|
|
if colonIdx == -1 {
|
|
continue
|
|
}
|
|
|
|
key := strings.TrimSpace(line[:colonIdx])
|
|
value := strings.TrimSpace(line[colonIdx+1:])
|
|
if value == "null" || value == "" {
|
|
value = ""
|
|
}
|
|
|
|
switch strings.ToLower(key) {
|
|
case "role_type":
|
|
fields.RoleType = value
|
|
case "rig":
|
|
fields.Rig = value
|
|
case "agent_state":
|
|
fields.AgentState = value
|
|
case "hook_bead":
|
|
fields.HookBead = value
|
|
case "role_bead":
|
|
fields.RoleBead = value
|
|
case "cleanup_status":
|
|
fields.CleanupStatus = value
|
|
case "active_mr":
|
|
fields.ActiveMR = value
|
|
case "notification_level":
|
|
fields.NotificationLevel = value
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
|
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
|
// Use AgentBeadID() helper to generate correct IDs.
|
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
|
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
|
description := FormatAgentDescription(title, fields)
|
|
|
|
args := []string{"create", "--json",
|
|
"--id=" + id,
|
|
"--type=agent",
|
|
"--title=" + title,
|
|
"--description=" + description,
|
|
}
|
|
|
|
// Default actor from BD_ACTOR env var for provenance tracking
|
|
if actor := os.Getenv("BD_ACTOR"); 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)
|
|
}
|
|
|
|
// Set the role slot if specified (this is the authoritative storage)
|
|
if fields != nil && fields.RoleBead != "" {
|
|
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
|
// Non-fatal: warn but continue
|
|
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Set the hook slot if specified (this is the authoritative storage)
|
|
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
|
// agent's hook slot is empty. See mi-619.
|
|
if fields != nil && fields.HookBead != "" {
|
|
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
|
// Non-fatal: warn but continue - description text has the backup
|
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
|
}
|
|
}
|
|
|
|
return &issue, nil
|
|
}
|
|
|
|
// UpdateAgentState updates the agent_state field in an agent bead.
|
|
// Optionally updates hook_bead if provided.
|
|
//
|
|
// IMPORTANT: This function uses the proper bd commands to update agent fields:
|
|
// - `bd agent state` for agent_state (uses SQLite column directly)
|
|
// - `bd slot set/clear` for hook_bead (uses SQLite column directly)
|
|
//
|
|
// This ensures consistency with `bd slot show` and other beads commands.
|
|
// Previously, this function embedded these fields in the description text,
|
|
// which caused inconsistencies with bd slot commands (see GH #gt-9v52).
|
|
func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) error {
|
|
// Update agent state using bd agent state command
|
|
// This updates the agent_state column directly in SQLite
|
|
_, err := b.run("agent", "state", id, state)
|
|
if err != nil {
|
|
return fmt.Errorf("updating agent state: %w", err)
|
|
}
|
|
|
|
// Update hook_bead if provided
|
|
if hookBead != nil {
|
|
if *hookBead != "" {
|
|
// Set the hook using bd slot set
|
|
// This updates the hook_bead column directly in SQLite
|
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
|
if err != nil {
|
|
// If slot is already occupied, clear it first then retry
|
|
// This handles re-slinging scenarios where we're updating the hook
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "already occupied") {
|
|
_, _ = b.run("slot", "clear", id, "hook")
|
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("setting hook: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
// Clear the hook
|
|
_, err = b.run("slot", "clear", id, "hook")
|
|
if err != nil {
|
|
return fmt.Errorf("clearing hook: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetHookBead sets the hook_bead slot on an agent bead.
|
|
// This is a convenience wrapper that only sets the hook without changing agent_state.
|
|
// Per gt-zecmc: agent_state ("running", "dead", "idle") is observable from tmux
|
|
// and should not be recorded in beads ("discover, don't track" principle).
|
|
func (b *Beads) SetHookBead(agentBeadID, hookBeadID string) error {
|
|
// Set the hook using bd slot set
|
|
// This updates the hook_bead column directly in SQLite
|
|
_, err := b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
|
if err != nil {
|
|
// If slot is already occupied, clear it first then retry
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "already occupied") {
|
|
_, _ = b.run("slot", "clear", agentBeadID, "hook")
|
|
_, err = b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("setting hook: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearHookBead clears the hook_bead slot on an agent bead.
|
|
// Used when work is complete or unslung.
|
|
func (b *Beads) ClearHookBead(agentBeadID string) error {
|
|
_, err := b.run("slot", "clear", agentBeadID, "hook")
|
|
if err != nil {
|
|
return fmt.Errorf("clearing hook: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateAgentCleanupStatus updates the cleanup_status field in an agent bead.
|
|
// This is called by the polecat to self-report its git state (ZFC compliance).
|
|
// Valid statuses: clean, has_uncommitted, has_stash, has_unpushed
|
|
func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus string) error {
|
|
// First get current issue to preserve other fields
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse existing fields
|
|
fields := ParseAgentFields(issue.Description)
|
|
fields.CleanupStatus = cleanupStatus
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// UpdateAgentActiveMR updates the active_mr field in an agent bead.
|
|
// This links the agent to their current merge request for traceability.
|
|
// Pass empty string to clear the field (e.g., after merge completes).
|
|
func (b *Beads) UpdateAgentActiveMR(id string, activeMR string) error {
|
|
// First get current issue to preserve other fields
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse existing fields
|
|
fields := ParseAgentFields(issue.Description)
|
|
fields.ActiveMR = activeMR
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// UpdateAgentNotificationLevel updates the notification_level field in an agent bead.
|
|
// Valid levels: verbose, normal, muted (DND mode).
|
|
// Pass empty string to reset to default (normal).
|
|
func (b *Beads) UpdateAgentNotificationLevel(id string, level string) error {
|
|
// Validate level
|
|
if level != "" && level != NotifyVerbose && level != NotifyNormal && level != NotifyMuted {
|
|
return fmt.Errorf("invalid notification level %q: must be verbose, normal, or muted", level)
|
|
}
|
|
|
|
// First get current issue to preserve other fields
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse existing fields
|
|
fields := ParseAgentFields(issue.Description)
|
|
fields.NotificationLevel = level
|
|
|
|
// Format new description
|
|
description := FormatAgentDescription(issue.Title, fields)
|
|
|
|
return b.Update(id, UpdateOptions{Description: &description})
|
|
}
|
|
|
|
// GetAgentNotificationLevel returns the notification level for an agent.
|
|
// Returns "normal" if not set (the default).
|
|
func (b *Beads) GetAgentNotificationLevel(id string) (string, error) {
|
|
_, fields, err := b.GetAgentBead(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if fields == nil {
|
|
return NotifyNormal, nil
|
|
}
|
|
if fields.NotificationLevel == "" {
|
|
return NotifyNormal, nil
|
|
}
|
|
return fields.NotificationLevel, nil
|
|
}
|
|
|
|
// DeleteAgentBead permanently deletes an agent bead.
|
|
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
|
func (b *Beads) DeleteAgentBead(id string) error {
|
|
_, err := b.run("delete", id, "--hard", "--force")
|
|
return err
|
|
}
|
|
|
|
// GetAgentBead retrieves an agent bead by ID.
|
|
// Returns nil if not found.
|
|
func (b *Beads) GetAgentBead(id string) (*Issue, *AgentFields, error) {
|
|
issue, err := b.Show(id)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
if issue.Type != "agent" {
|
|
return nil, nil, fmt.Errorf("issue %s is not an agent bead (type: %s)", id, issue.Type)
|
|
}
|
|
|
|
fields := ParseAgentFields(issue.Description)
|
|
return issue, fields, nil
|
|
}
|
|
|
|
// Agent bead ID naming convention:
|
|
// prefix-rig-role-name
|
|
//
|
|
// Examples:
|
|
// - gt-mayor (town-level, no rig)
|
|
// - gt-deacon (town-level, no rig)
|
|
// - gt-gastown-witness (rig-level singleton)
|
|
// - gt-gastown-refinery (rig-level singleton)
|
|
// - gt-gastown-crew-max (rig-level named agent)
|
|
// - gt-gastown-polecat-Toast (rig-level named agent)
|
|
|
|
// AgentBeadIDWithPrefix generates an agent bead ID using the specified prefix.
|
|
// The prefix should NOT include the hyphen (e.g., "gt", "bd", not "gt-", "bd-").
|
|
// For town-level agents (mayor, deacon), pass empty rig and name.
|
|
// For rig-level singletons (witness, refinery), pass empty name.
|
|
// For named agents (crew, polecat), pass all three.
|
|
func AgentBeadIDWithPrefix(prefix, rig, role, name string) string {
|
|
if rig == "" {
|
|
// Town-level agent: prefix-mayor, prefix-deacon
|
|
return prefix + "-" + role
|
|
}
|
|
if name == "" {
|
|
// Rig-level singleton: prefix-rig-witness, prefix-rig-refinery
|
|
return prefix + "-" + rig + "-" + role
|
|
}
|
|
// Rig-level named agent: prefix-rig-role-name
|
|
return prefix + "-" + rig + "-" + role + "-" + name
|
|
}
|
|
|
|
// AgentBeadID generates the canonical agent bead ID using "gt" prefix.
|
|
// For non-gastown rigs, use AgentBeadIDWithPrefix with the rig's configured prefix.
|
|
func AgentBeadID(rig, role, name string) string {
|
|
return AgentBeadIDWithPrefix("gt", rig, role, name)
|
|
}
|
|
|
|
// MayorBeadID returns the Mayor agent bead ID.
|
|
//
|
|
// Deprecated: Use MayorBeadIDTown() for town-level beads (hq- prefix).
|
|
// This function returns "gt-mayor" which is for rig-level storage.
|
|
// Town-level agents like Mayor should use the hq- prefix.
|
|
func MayorBeadID() string {
|
|
return "gt-mayor"
|
|
}
|
|
|
|
// DeaconBeadID returns the Deacon agent bead ID.
|
|
//
|
|
// Deprecated: Use DeaconBeadIDTown() for town-level beads (hq- prefix).
|
|
// This function returns "gt-deacon" which is for rig-level storage.
|
|
// Town-level agents like Deacon should use the hq- prefix.
|
|
func DeaconBeadID() string {
|
|
return "gt-deacon"
|
|
}
|
|
|
|
// DogBeadID returns a Dog agent bead ID.
|
|
// Dogs are town-level agents, so they follow the pattern: gt-dog-<name>
|
|
// Deprecated: Use DogBeadIDTown() for town-level beads with hq- prefix.
|
|
// Dogs are town-level agents and should use hq-dog-<name>, not gt-dog-<name>.
|
|
func DogBeadID(name string) string {
|
|
return "gt-dog-" + name
|
|
}
|
|
|
|
// DogRoleBeadID returns the Dog role bead ID.
|
|
func DogRoleBeadID() string {
|
|
return RoleBeadID("dog")
|
|
}
|
|
|
|
// CreateDogAgentBead creates an agent bead for a dog.
|
|
// Dogs use a different schema than other agents - they use labels for metadata.
|
|
// Returns the created issue or an error.
|
|
func (b *Beads) CreateDogAgentBead(name, location string) (*Issue, error) {
|
|
title := fmt.Sprintf("Dog: %s", name)
|
|
labels := []string{
|
|
"role_type:dog",
|
|
"rig:town",
|
|
"location:" + location,
|
|
}
|
|
|
|
args := []string{
|
|
"create", "--json",
|
|
"--type=agent",
|
|
"--role-type=dog",
|
|
"--title=" + title,
|
|
"--labels=" + strings.Join(labels, ","),
|
|
}
|
|
|
|
// Default actor from BD_ACTOR env var for provenance tracking
|
|
if actor := os.Getenv("BD_ACTOR"); 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
|
|
}
|
|
|
|
// FindDogAgentBead finds the agent bead for a dog by name.
|
|
// Searches for agent beads with role_type:dog and matching title.
|
|
// Returns nil if not found.
|
|
func (b *Beads) FindDogAgentBead(name string) (*Issue, error) {
|
|
// List all agent beads and filter by role_type:dog label
|
|
issues, err := b.List(ListOptions{
|
|
Type: "agent",
|
|
Status: "all",
|
|
Priority: -1, // No priority filter
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing agents: %w", err)
|
|
}
|
|
|
|
expectedTitle := fmt.Sprintf("Dog: %s", name)
|
|
for _, issue := range issues {
|
|
// Check title match and role_type:dog label
|
|
if issue.Title == expectedTitle {
|
|
for _, label := range issue.Labels {
|
|
if label == "role_type:dog" {
|
|
return issue, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// DeleteDogAgentBead finds and deletes the agent bead for a dog.
|
|
// Returns nil if the bead doesn't exist (idempotent).
|
|
func (b *Beads) DeleteDogAgentBead(name string) error {
|
|
issue, err := b.FindDogAgentBead(name)
|
|
if err != nil {
|
|
return fmt.Errorf("finding dog bead: %w", err)
|
|
}
|
|
if issue == nil {
|
|
return nil // Already doesn't exist - idempotent
|
|
}
|
|
|
|
err = b.DeleteAgentBead(issue.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting bead %s: %w", issue.ID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WitnessBeadIDWithPrefix returns the Witness agent bead ID for a rig using the specified prefix.
|
|
func WitnessBeadIDWithPrefix(prefix, rig string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "witness", "")
|
|
}
|
|
|
|
// WitnessBeadID returns the Witness agent bead ID for a rig using "gt" prefix.
|
|
func WitnessBeadID(rig string) string {
|
|
return WitnessBeadIDWithPrefix("gt", rig)
|
|
}
|
|
|
|
// RefineryBeadIDWithPrefix returns the Refinery agent bead ID for a rig using the specified prefix.
|
|
func RefineryBeadIDWithPrefix(prefix, rig string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "refinery", "")
|
|
}
|
|
|
|
// RefineryBeadID returns the Refinery agent bead ID for a rig using "gt" prefix.
|
|
func RefineryBeadID(rig string) string {
|
|
return RefineryBeadIDWithPrefix("gt", rig)
|
|
}
|
|
|
|
// CrewBeadIDWithPrefix returns a Crew worker agent bead ID using the specified prefix.
|
|
func CrewBeadIDWithPrefix(prefix, rig, name string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "crew", name)
|
|
}
|
|
|
|
// CrewBeadID returns a Crew worker agent bead ID using "gt" prefix.
|
|
func CrewBeadID(rig, name string) string {
|
|
return CrewBeadIDWithPrefix("gt", rig, name)
|
|
}
|
|
|
|
// PolecatBeadIDWithPrefix returns a Polecat agent bead ID using the specified prefix.
|
|
func PolecatBeadIDWithPrefix(prefix, rig, name string) string {
|
|
return AgentBeadIDWithPrefix(prefix, rig, "polecat", name)
|
|
}
|
|
|
|
// PolecatBeadID returns a Polecat agent bead ID using "gt" prefix.
|
|
func PolecatBeadID(rig, name string) string {
|
|
return PolecatBeadIDWithPrefix("gt", rig, name)
|
|
}
|
|
|
|
// ParseAgentBeadID parses an agent bead ID into its components.
|
|
// Returns rig, role, name, and whether parsing succeeded.
|
|
// For town-level agents, rig will be empty.
|
|
// For singletons, name will be empty.
|
|
// Accepts any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
|
func ParseAgentBeadID(id string) (rig, role, name string, ok bool) {
|
|
// Find the prefix (everything before the first hyphen)
|
|
// Valid prefixes are 2-3 characters (e.g., "gt", "bd", "hq")
|
|
hyphenIdx := strings.Index(id, "-")
|
|
if hyphenIdx < 2 || hyphenIdx > 3 {
|
|
return "", "", "", false
|
|
}
|
|
|
|
rest := id[hyphenIdx+1:]
|
|
parts := strings.Split(rest, "-")
|
|
|
|
switch len(parts) {
|
|
case 1:
|
|
// Town-level: gt-mayor, bd-deacon
|
|
return "", parts[0], "", true
|
|
case 2:
|
|
// Could be rig-level singleton (gt-gastown-witness) or
|
|
// town-level named (gt-dog-alpha for dogs)
|
|
if parts[0] == "dog" {
|
|
// Dogs are town-level named agents: gt-dog-<name>
|
|
return "", "dog", parts[1], true
|
|
}
|
|
// Rig-level singleton: gt-gastown-witness
|
|
return parts[0], parts[1], "", true
|
|
case 3:
|
|
// Rig-level named: gt-gastown-crew-max, bd-beads-polecat-pearl
|
|
return parts[0], parts[1], parts[2], true
|
|
default:
|
|
// Handle names with hyphens: gt-gastown-polecat-my-agent-name
|
|
// or gt-dog-my-agent-name
|
|
if len(parts) >= 3 {
|
|
if parts[0] == "dog" {
|
|
// Dog with hyphenated name: gt-dog-my-dog-name
|
|
return "", "dog", strings.Join(parts[1:], "-"), true
|
|
}
|
|
return parts[0], parts[1], strings.Join(parts[2:], "-"), true
|
|
}
|
|
return "", "", "", false
|
|
}
|
|
}
|
|
|
|
// IsAgentSessionBead returns true if the bead ID represents an agent session molecule.
|
|
// Agent session beads follow patterns like gt-mayor, bd-beads-witness, gt-gastown-crew-joe.
|
|
// Supports any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
|
// These are used to track agent state and update frequently, which can create noise.
|
|
func IsAgentSessionBead(beadID string) bool {
|
|
_, role, _, ok := ParseAgentBeadID(beadID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
// Known agent roles
|
|
switch role {
|
|
case "mayor", "deacon", "witness", "refinery", "crew", "polecat", "dog":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Role bead ID naming convention:
|
|
// Role beads are stored in town beads (~/.beads/) with hq- prefix.
|
|
//
|
|
// Canonical format: hq-<role>-role
|
|
//
|
|
// Examples:
|
|
// - hq-mayor-role
|
|
// - hq-deacon-role
|
|
// - hq-witness-role
|
|
// - hq-refinery-role
|
|
// - hq-crew-role
|
|
// - hq-polecat-role
|
|
//
|
|
// Use RoleBeadIDTown() to get canonical role bead IDs.
|
|
// The legacy RoleBeadID() function returns gt-<role>-role for backward compatibility.
|
|
|
|
// RoleBeadID returns the role bead ID for a given role type.
|
|
// Role beads define lifecycle configuration for each agent type.
|
|
// Deprecated: Use RoleBeadIDTown() for town-level beads with hq- prefix.
|
|
// Role beads are global templates and should use hq-<role>-role, not gt-<role>-role.
|
|
func RoleBeadID(roleType string) string {
|
|
return "gt-" + roleType + "-role"
|
|
}
|
|
|
|
// MayorRoleBeadID returns the Mayor role bead ID.
|
|
func MayorRoleBeadID() string {
|
|
return RoleBeadID("mayor")
|
|
}
|
|
|
|
// DeaconRoleBeadID returns the Deacon role bead ID.
|
|
func DeaconRoleBeadID() string {
|
|
return RoleBeadID("deacon")
|
|
}
|
|
|
|
// WitnessRoleBeadID returns the Witness role bead ID.
|
|
func WitnessRoleBeadID() string {
|
|
return RoleBeadID("witness")
|
|
}
|
|
|
|
// RefineryRoleBeadID returns the Refinery role bead ID.
|
|
func RefineryRoleBeadID() string {
|
|
return RoleBeadID("refinery")
|
|
}
|
|
|
|
// CrewRoleBeadID returns the Crew role bead ID.
|
|
func CrewRoleBeadID() string {
|
|
return RoleBeadID("crew")
|
|
}
|
|
|
|
// PolecatRoleBeadID returns the Polecat role bead ID.
|
|
func PolecatRoleBeadID() string {
|
|
return RoleBeadID("polecat")
|
|
}
|
|
|
|
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
|
|
// Returns nil, nil if the role bead doesn't exist or has no config.
|
|
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
|
|
issue, err := b.Show(roleBeadID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if issue.Type != "role" {
|
|
return nil, fmt.Errorf("bead %s is not a role bead (type: %s)", roleBeadID, issue.Type)
|
|
}
|
|
|
|
return ParseRoleConfig(issue.Description), nil
|
|
}
|
|
|
|
// FindMRForBranch searches for an existing merge-request bead for the given branch.
|
|
// Returns the MR bead if found, nil if not found.
|
|
// This enables idempotent `gt done` - if an MR already exists, we skip creation.
|
|
func (b *Beads) FindMRForBranch(branch string) (*Issue, error) {
|
|
// List all merge-request beads (open status only - closed MRs are already processed)
|
|
issues, err := b.List(ListOptions{
|
|
Status: "open",
|
|
Type: "merge-request",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Search for one matching this branch
|
|
// MR description format: "branch: <branch>\ntarget: ..."
|
|
branchPrefix := "branch: " + branch + "\n"
|
|
for _, issue := range issues {
|
|
if strings.HasPrefix(issue.Description, branchPrefix) {
|
|
return issue, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// AddGateWaiter registers an agent as a waiter on a gate bead.
|
|
// When the gate closes, the waiter will receive a wake notification via gt gate wake.
|
|
// The waiter is typically the polecat's address (e.g., "gastown/polecats/Toast").
|
|
func (b *Beads) AddGateWaiter(gateID, waiter string) error {
|
|
// Use bd gate add-waiter to register the waiter on the gate
|
|
// This adds the waiter to the gate's native waiters field
|
|
_, err := b.run("gate", "add-waiter", gateID, waiter)
|
|
if err != nil {
|
|
return fmt.Errorf("adding gate waiter: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ===== Merge Slot Functions (serialized conflict resolution) =====
|
|
|
|
// MergeSlotStatus represents the result of checking a merge slot.
|
|
type MergeSlotStatus struct {
|
|
ID string `json:"id"`
|
|
Available bool `json:"available"`
|
|
Holder string `json:"holder,omitempty"`
|
|
Waiters []string `json:"waiters,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// MergeSlotCreate creates the merge slot bead for the current rig.
|
|
// The slot is used for serialized conflict resolution in the merge queue.
|
|
// Returns the slot ID if successful.
|
|
func (b *Beads) MergeSlotCreate() (string, error) {
|
|
out, err := b.run("merge-slot", "create", "--json")
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating merge slot: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(out, &result); err != nil {
|
|
return "", fmt.Errorf("parsing merge-slot create output: %w", err)
|
|
}
|
|
|
|
return result.ID, nil
|
|
}
|
|
|
|
// MergeSlotCheck checks the availability of the merge slot.
|
|
// Returns the current status including holder and waiters if held.
|
|
func (b *Beads) MergeSlotCheck() (*MergeSlotStatus, error) {
|
|
out, err := b.run("merge-slot", "check", "--json")
|
|
if err != nil {
|
|
// Check if slot doesn't exist
|
|
if strings.Contains(err.Error(), "not found") {
|
|
return &MergeSlotStatus{Error: "not found"}, nil
|
|
}
|
|
return nil, fmt.Errorf("checking merge slot: %w", err)
|
|
}
|
|
|
|
var status MergeSlotStatus
|
|
if err := json.Unmarshal(out, &status); err != nil {
|
|
return nil, fmt.Errorf("parsing merge-slot check output: %w", err)
|
|
}
|
|
|
|
return &status, nil
|
|
}
|
|
|
|
// MergeSlotAcquire attempts to acquire the merge slot for exclusive access.
|
|
// If holder is empty, defaults to BD_ACTOR environment variable.
|
|
// If addWaiter is true and the slot is held, the requester is added to the waiters queue.
|
|
// Returns the acquisition result.
|
|
func (b *Beads) MergeSlotAcquire(holder string, addWaiter bool) (*MergeSlotStatus, error) {
|
|
args := []string{"merge-slot", "acquire", "--json"}
|
|
if holder != "" {
|
|
args = append(args, "--holder="+holder)
|
|
}
|
|
if addWaiter {
|
|
args = append(args, "--wait")
|
|
}
|
|
|
|
out, err := b.run(args...)
|
|
if err != nil {
|
|
// Parse the output even on error - it may contain useful info
|
|
var status MergeSlotStatus
|
|
if jsonErr := json.Unmarshal(out, &status); jsonErr == nil {
|
|
return &status, nil
|
|
}
|
|
return nil, fmt.Errorf("acquiring merge slot: %w", err)
|
|
}
|
|
|
|
var status MergeSlotStatus
|
|
if err := json.Unmarshal(out, &status); err != nil {
|
|
return nil, fmt.Errorf("parsing merge-slot acquire output: %w", err)
|
|
}
|
|
|
|
return &status, nil
|
|
}
|
|
|
|
// MergeSlotRelease releases the merge slot after conflict resolution completes.
|
|
// If holder is provided, it verifies the slot is held by that holder before releasing.
|
|
func (b *Beads) MergeSlotRelease(holder string) error {
|
|
args := []string{"merge-slot", "release", "--json"}
|
|
if holder != "" {
|
|
args = append(args, "--holder="+holder)
|
|
}
|
|
|
|
out, err := b.run(args...)
|
|
if err != nil {
|
|
return fmt.Errorf("releasing merge slot: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Released bool `json:"released"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(out, &result); err != nil {
|
|
return fmt.Errorf("parsing merge-slot release output: %w", err)
|
|
}
|
|
|
|
if !result.Released && result.Error != "" {
|
|
return fmt.Errorf("slot release failed: %s", result.Error)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MergeSlotEnsureExists creates the merge slot if it doesn't exist.
|
|
// This is idempotent - safe to call multiple times.
|
|
func (b *Beads) MergeSlotEnsureExists() (string, error) {
|
|
// Check if slot exists first
|
|
status, err := b.MergeSlotCheck()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if status.Error == "not found" {
|
|
// Create it
|
|
return b.MergeSlotCreate()
|
|
}
|
|
|
|
return status.ID, nil
|
|
}
|
|
|
|
// ===== Rig Identity Beads =====
|
|
|
|
// RigFields contains the fields specific to rig identity beads.
|
|
type RigFields struct {
|
|
Repo string // Git URL for the rig's repository
|
|
Prefix string // Beads prefix for this rig (e.g., "gt", "bd")
|
|
State string // Operational state: active, archived, maintenance
|
|
}
|
|
|
|
// FormatRigDescription formats the description field for a rig identity bead.
|
|
func FormatRigDescription(name string, fields *RigFields) string {
|
|
if fields == nil {
|
|
return ""
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, fmt.Sprintf("Rig identity bead for %s.", name))
|
|
lines = append(lines, "")
|
|
|
|
if fields.Repo != "" {
|
|
lines = append(lines, fmt.Sprintf("repo: %s", fields.Repo))
|
|
}
|
|
if fields.Prefix != "" {
|
|
lines = append(lines, fmt.Sprintf("prefix: %s", fields.Prefix))
|
|
}
|
|
if fields.State != "" {
|
|
lines = append(lines, fmt.Sprintf("state: %s", fields.State))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// ParseRigFields extracts rig fields from an issue's description.
|
|
func ParseRigFields(description string) *RigFields {
|
|
fields := &RigFields{}
|
|
|
|
for _, line := range strings.Split(description, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
colonIdx := strings.Index(line, ":")
|
|
if colonIdx == -1 {
|
|
continue
|
|
}
|
|
|
|
key := strings.TrimSpace(line[:colonIdx])
|
|
value := strings.TrimSpace(line[colonIdx+1:])
|
|
if value == "null" || value == "" {
|
|
value = ""
|
|
}
|
|
|
|
switch strings.ToLower(key) {
|
|
case "repo":
|
|
fields.Repo = value
|
|
case "prefix":
|
|
fields.Prefix = value
|
|
case "state":
|
|
fields.State = value
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// CreateRigBead creates a rig identity bead for tracking rig metadata.
|
|
// The ID format is: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
|
// Use RigBeadID() helper to generate correct IDs.
|
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
|
func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, error) {
|
|
description := FormatRigDescription(title, fields)
|
|
|
|
args := []string{"create", "--json",
|
|
"--id=" + id,
|
|
"--type=rig",
|
|
"--title=" + title,
|
|
"--description=" + description,
|
|
}
|
|
|
|
// Default actor from BD_ACTOR env var for provenance tracking
|
|
if actor := os.Getenv("BD_ACTOR"); 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
|
|
}
|
|
|
|
// RigBeadIDWithPrefix generates a rig identity bead ID using the specified prefix.
|
|
// Format: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
|
func RigBeadIDWithPrefix(prefix, name string) string {
|
|
return fmt.Sprintf("%s-rig-%s", prefix, name)
|
|
}
|
|
|
|
// RigBeadID generates a rig identity bead ID using "gt" prefix.
|
|
// For non-gastown rigs, use RigBeadIDWithPrefix with the rig's configured prefix.
|
|
func RigBeadID(name string) string {
|
|
return RigBeadIDWithPrefix("gt", name)
|
|
}
|