feat: Add --assignee flag to template instantiate, fix mail inbox --identity
- Add --assignee flag to `bd template instantiate` for work delegation - Assigns the root epic to specified agent/user - Child issues retain their template-defined assignees - Enables Gas Town orchestration: instantiate + assign in one step - Add missing --identity flag to `bd mail inbox` for consistency - All mail subcommands now support --identity override - Delete obsolete HANDOFF-template-redesign.md (work complete) - Add test for assignee override behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,116 +0,0 @@
|
||||
# Handoff: Template System Redesign (bd-r6a)
|
||||
|
||||
## Status
|
||||
|
||||
- **bd-r6a.1**: DONE - Reverted YAML workflow code, deleted workflow.go and types
|
||||
- **bd-r6a.2**: IN PROGRESS - Implementing subgraph cloning with variable substitution
|
||||
|
||||
## What Was Removed
|
||||
|
||||
- `cmd/bd/workflow.go` - entire file
|
||||
- `cmd/bd/templates/workflows/` - YAML templates directory
|
||||
- `internal/types/workflow.go` - WorkflowTemplate types
|
||||
|
||||
Build passes. Tests pass.
|
||||
|
||||
## The New Design
|
||||
|
||||
### Core Principle
|
||||
|
||||
**Templates are just Beads.** An epic with the `template` label and `{{variable}}` placeholders in titles/descriptions.
|
||||
|
||||
Beads provides **primitives**:
|
||||
- Clone a subgraph (epic + children + dependencies)
|
||||
- Substitute `{{variables}}`
|
||||
- Return ID mapping (old → new)
|
||||
|
||||
Orchestrators (Gas Town) provide **composition**:
|
||||
- Multiple instantiations
|
||||
- Cross-template dependencies
|
||||
- Dynamic task generation
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
bd template list # List templates (label=template)
|
||||
bd template instantiate <id> --var key=value # Clone + substitute
|
||||
bd template instantiate <id> --dry-run # Preview
|
||||
```
|
||||
|
||||
### Gas Town Use Case: Witness
|
||||
|
||||
The Witness manages polecat lifecycles with a dynamic DAG:
|
||||
|
||||
```
|
||||
Witness Round (1 instance)
|
||||
├── Check context
|
||||
├── Initialize tracking
|
||||
├── [polecat tasks wired in by Gas Town]
|
||||
├── Submit to merge queue
|
||||
└── Finalize
|
||||
|
||||
Polecat Lifecycle (N instances, one per polecat)
|
||||
├── Verify startup
|
||||
├── Monitor progress
|
||||
├── Verify shutdown
|
||||
└── Decommission
|
||||
```
|
||||
|
||||
Gas Town instantiates templates in a loop and wires dependencies between them.
|
||||
|
||||
## Implementation Started
|
||||
|
||||
Was creating `cmd/bd/template.go` with:
|
||||
|
||||
- `templateCmd` - parent command
|
||||
- `templateListCmd` - list templates (issues with template label)
|
||||
- `templateInstantiateCmd` - clone subgraph with substitution
|
||||
- `TemplateSubgraph` struct - holds issues + dependencies
|
||||
- `loadTemplateSubgraph()` - recursive load of epic + descendants
|
||||
- `cloneSubgraph()` - create new issues with ID remapping
|
||||
- `extractVariables()` - find `{{name}}` patterns
|
||||
- `substituteVariables()` - replace patterns with values
|
||||
|
||||
File was not written yet (got interrupted).
|
||||
|
||||
## Key Functions Needed
|
||||
|
||||
```go
|
||||
// Load template and all descendants
|
||||
func loadTemplateSubgraph(templateID string) (*TemplateSubgraph, error)
|
||||
|
||||
// Clone with substitution, return new epic ID and ID mapping
|
||||
func cloneSubgraph(subgraph *TemplateSubgraph, vars map[string]string) (string, map[string]string, error)
|
||||
|
||||
// Extract {{variable}} patterns
|
||||
func extractVariables(text string) []string
|
||||
|
||||
// Replace {{variable}} with values
|
||||
func substituteVariables(text string, vars map[string]string) string
|
||||
```
|
||||
|
||||
## Remaining Tasks
|
||||
|
||||
1. **bd-r6a.2**: Implement subgraph cloning (in progress)
|
||||
2. **bd-r6a.3**: Create version-bump as native Beads template
|
||||
3. **bd-r6a.4**: Add `bd template list` command
|
||||
4. **bd-r6a.5**: Update documentation
|
||||
|
||||
## HOP Context
|
||||
|
||||
Templates feed into HOP vision:
|
||||
- Work is fractal (templates are reusable work patterns)
|
||||
- Beads IS the ledger (templates are ledger entries)
|
||||
- Gas Town is execution engine (composes templates into swarms)
|
||||
|
||||
See `~/gt/hop/CONTEXT.md` for full HOP context.
|
||||
|
||||
## To Resume
|
||||
|
||||
```bash
|
||||
cd /Users/stevey/src/dave/beads
|
||||
bd show bd-r6a # See epic and tasks
|
||||
bd ready # bd-r6a.2 should be ready
|
||||
bd update bd-r6a.2 --status=in_progress
|
||||
# Create cmd/bd/template.go with the design above
|
||||
```
|
||||
@@ -130,6 +130,7 @@ func init() {
|
||||
// Inbox command flags
|
||||
mailInboxCmd.Flags().StringVar(&mailFrom, "from", "", "Filter by sender")
|
||||
mailInboxCmd.Flags().IntVar(&mailPriorityFlag, "priority", -1, "Filter by priority (0-4)")
|
||||
mailInboxCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override recipient identity")
|
||||
|
||||
// Read command flags
|
||||
mailReadCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity for access check")
|
||||
|
||||
@@ -383,6 +383,7 @@ Example:
|
||||
ctx := rootCtx
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
||||
assignee, _ := cmd.Flags().GetString("assignee")
|
||||
|
||||
// Parse variables
|
||||
vars := make(map[string]string)
|
||||
@@ -446,7 +447,11 @@ Example:
|
||||
fmt.Printf("\nDry run: would create %d issues from template %s\n\n", len(subgraph.Issues), templateID)
|
||||
for _, issue := range subgraph.Issues {
|
||||
newTitle := substituteVariables(issue.Title, vars)
|
||||
fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID)
|
||||
suffix := ""
|
||||
if issue.ID == subgraph.Root.ID && assignee != "" {
|
||||
suffix = fmt.Sprintf(" (assignee: %s)", assignee)
|
||||
}
|
||||
fmt.Printf(" - %s (from %s)%s\n", newTitle, issue.ID, suffix)
|
||||
}
|
||||
if len(vars) > 0 {
|
||||
fmt.Printf("\nVariables:\n")
|
||||
@@ -458,7 +463,7 @@ Example:
|
||||
}
|
||||
|
||||
// Clone the subgraph
|
||||
result, err := cloneSubgraph(ctx, store, subgraph, vars, actor)
|
||||
result, err := cloneSubgraph(ctx, store, subgraph, vars, assignee, actor)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -484,6 +489,7 @@ func init() {
|
||||
|
||||
templateInstantiateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
|
||||
templateInstantiateCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
||||
templateInstantiateCmd.Flags().String("assignee", "", "Assign the root epic to this agent/user")
|
||||
|
||||
templateCmd.AddCommand(templateListCmd)
|
||||
templateCmd.AddCommand(templateShowCmd)
|
||||
@@ -734,7 +740,8 @@ func substituteVariables(text string, vars map[string]string) string {
|
||||
}
|
||||
|
||||
// cloneSubgraph creates new issues from the template with variable substitution
|
||||
func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, vars map[string]string, actorName string) (*InstantiateResult, error) {
|
||||
// If assignee is non-empty, it will be set on the root epic
|
||||
func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, vars map[string]string, assignee string, actorName string) (*InstantiateResult, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("no database connection")
|
||||
}
|
||||
@@ -746,6 +753,12 @@ func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSub
|
||||
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||
// First pass: create all issues with new IDs
|
||||
for _, oldIssue := range subgraph.Issues {
|
||||
// Determine assignee: use override for root epic, otherwise keep template's
|
||||
issueAssignee := oldIssue.Assignee
|
||||
if oldIssue.ID == subgraph.Root.ID && assignee != "" {
|
||||
issueAssignee = assignee
|
||||
}
|
||||
|
||||
newIssue := &types.Issue{
|
||||
// Don't set ID - let the system generate it
|
||||
Title: substituteVariables(oldIssue.Title, vars),
|
||||
@@ -756,7 +769,7 @@ func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSub
|
||||
Status: types.StatusOpen, // Always start fresh
|
||||
Priority: oldIssue.Priority,
|
||||
IssueType: oldIssue.IssueType,
|
||||
Assignee: oldIssue.Assignee,
|
||||
Assignee: issueAssignee,
|
||||
EstimatedMinutes: oldIssue.EstimatedMinutes,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
|
||||
@@ -450,7 +450,7 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
}
|
||||
|
||||
vars := map[string]string{"version": "2.0.0"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, vars, "test-user")
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, vars, "", "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
@@ -490,7 +490,7 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
}
|
||||
|
||||
vars := map[string]string{"service": "api-gateway"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, vars, "test-user")
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, vars, "", "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
@@ -549,7 +549,7 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, nil, "test-user")
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, nil, "", "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
@@ -562,6 +562,52 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
t.Errorf("Status = %s, want %s", newEpic.Status, types.StatusOpen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("assignee override applies to root epic only", func(t *testing.T) {
|
||||
epic := h.createIssue("Root Epic", "", types.TypeEpic, 1)
|
||||
child := h.createIssue("Child Task", "", types.TypeTask, 2)
|
||||
h.addParentChild(child.ID, epic.ID)
|
||||
|
||||
// Set assignees on template
|
||||
err := s.UpdateIssue(ctx, epic.ID, map[string]interface{}{"assignee": "template-owner"}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set epic assignee: %v", err)
|
||||
}
|
||||
err = s.UpdateIssue(ctx, child.ID, map[string]interface{}{"assignee": "child-owner"}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set child assignee: %v", err)
|
||||
}
|
||||
|
||||
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
// Clone with assignee override
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, nil, "new-assignee", "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
// Root epic should have override assignee
|
||||
newEpic, err := s.GetIssue(ctx, result.NewEpicID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cloned epic: %v", err)
|
||||
}
|
||||
if newEpic.Assignee != "new-assignee" {
|
||||
t.Errorf("Epic assignee = %q, want %q", newEpic.Assignee, "new-assignee")
|
||||
}
|
||||
|
||||
// Child should keep template assignee
|
||||
newChildID := result.IDMapping[child.ID]
|
||||
newChild, err := s.GetIssue(ctx, newChildID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cloned child: %v", err)
|
||||
}
|
||||
if newChild.Assignee != "child-owner" {
|
||||
t.Errorf("Child assignee = %q, want %q", newChild.Assignee, "child-owner")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestExtractAllVariables tests extracting variables from entire subgraph
|
||||
|
||||
Reference in New Issue
Block a user