diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 731fa261..cf42168b 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -125,6 +125,46 @@ type IssueDep struct { 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" @@ -492,6 +532,118 @@ func (b *Beads) RemoveDependency(issue, dependsOn string) error { 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") diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index d74ab6dc..67a09276 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -1,6 +1,7 @@ package beads import ( + "encoding/json" "os" "path/filepath" "strings" @@ -1377,3 +1378,102 @@ func TestRoleBeadID(t *testing.T) { }) } } + +// TestDelegationStruct tests the Delegation struct serialization. +func TestDelegationStruct(t *testing.T) { + tests := []struct { + name string + delegation Delegation + wantJSON string + }{ + { + name: "full delegation", + delegation: Delegation{ + Parent: "hop://accenture.com/eng/proj-123/task-a", + Child: "hop://alice@example.com/main-town/gastown/gt-xyz", + DelegatedBy: "hop://accenture.com", + DelegatedTo: "hop://alice@example.com", + Terms: &DelegationTerms{ + Portion: "backend-api", + Deadline: "2025-06-01", + CreditShare: 80, + }, + CreatedAt: "2025-01-15T10:00:00Z", + }, + wantJSON: `{"parent":"hop://accenture.com/eng/proj-123/task-a","child":"hop://alice@example.com/main-town/gastown/gt-xyz","delegated_by":"hop://accenture.com","delegated_to":"hop://alice@example.com","terms":{"portion":"backend-api","deadline":"2025-06-01","credit_share":80},"created_at":"2025-01-15T10:00:00Z"}`, + }, + { + name: "minimal delegation", + delegation: Delegation{ + Parent: "gt-abc", + Child: "gt-xyz", + DelegatedBy: "steve", + DelegatedTo: "alice", + }, + wantJSON: `{"parent":"gt-abc","child":"gt-xyz","delegated_by":"steve","delegated_to":"alice"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.delegation) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + if string(got) != tt.wantJSON { + t.Errorf("json.Marshal = %s, want %s", string(got), tt.wantJSON) + } + + // Test round-trip + var parsed Delegation + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + if parsed.Parent != tt.delegation.Parent { + t.Errorf("parsed.Parent = %s, want %s", parsed.Parent, tt.delegation.Parent) + } + if parsed.Child != tt.delegation.Child { + t.Errorf("parsed.Child = %s, want %s", parsed.Child, tt.delegation.Child) + } + if parsed.DelegatedBy != tt.delegation.DelegatedBy { + t.Errorf("parsed.DelegatedBy = %s, want %s", parsed.DelegatedBy, tt.delegation.DelegatedBy) + } + if parsed.DelegatedTo != tt.delegation.DelegatedTo { + t.Errorf("parsed.DelegatedTo = %s, want %s", parsed.DelegatedTo, tt.delegation.DelegatedTo) + } + }) + } +} + +// TestDelegationTerms tests the DelegationTerms struct. +func TestDelegationTerms(t *testing.T) { + terms := &DelegationTerms{ + Portion: "frontend", + Deadline: "2025-03-15", + AcceptanceCriteria: "All tests passing, code reviewed", + CreditShare: 70, + } + + got, err := json.Marshal(terms) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var parsed DelegationTerms + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + if parsed.Portion != terms.Portion { + t.Errorf("parsed.Portion = %s, want %s", parsed.Portion, terms.Portion) + } + if parsed.Deadline != terms.Deadline { + t.Errorf("parsed.Deadline = %s, want %s", parsed.Deadline, terms.Deadline) + } + if parsed.AcceptanceCriteria != terms.AcceptanceCriteria { + t.Errorf("parsed.AcceptanceCriteria = %s, want %s", parsed.AcceptanceCriteria, terms.AcceptanceCriteria) + } + if parsed.CreditShare != terms.CreditShare { + t.Errorf("parsed.CreditShare = %d, want %d", parsed.CreditShare, terms.CreditShare) + } +}