Add delegation relationship type to beads (gt-6r18e.5)
Add Delegation and DelegationTerms structs for tracking work delegation between work units. This enables the HOP pattern of work flowing down and credit cascading up. New types: - Delegation: Links parent and child work units with delegated_by/to - DelegationTerms: Optional terms including portion, deadline, credit_share New functions: - AddDelegation: Create a delegation relationship - RemoveDelegation: Remove a delegation relationship - GetDelegation: Retrieve delegation info for a child work unit - ListDelegationsFrom: List all delegations from a parent 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user