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
|
// Inbox command flags
|
||||||
mailInboxCmd.Flags().StringVar(&mailFrom, "from", "", "Filter by sender")
|
mailInboxCmd.Flags().StringVar(&mailFrom, "from", "", "Filter by sender")
|
||||||
mailInboxCmd.Flags().IntVar(&mailPriorityFlag, "priority", -1, "Filter by priority (0-4)")
|
mailInboxCmd.Flags().IntVar(&mailPriorityFlag, "priority", -1, "Filter by priority (0-4)")
|
||||||
|
mailInboxCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override recipient identity")
|
||||||
|
|
||||||
// Read command flags
|
// Read command flags
|
||||||
mailReadCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity for access check")
|
mailReadCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity for access check")
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ Example:
|
|||||||
ctx := rootCtx
|
ctx := rootCtx
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
||||||
|
assignee, _ := cmd.Flags().GetString("assignee")
|
||||||
|
|
||||||
// Parse variables
|
// Parse variables
|
||||||
vars := make(map[string]string)
|
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)
|
fmt.Printf("\nDry run: would create %d issues from template %s\n\n", len(subgraph.Issues), templateID)
|
||||||
for _, issue := range subgraph.Issues {
|
for _, issue := range subgraph.Issues {
|
||||||
newTitle := substituteVariables(issue.Title, vars)
|
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 {
|
if len(vars) > 0 {
|
||||||
fmt.Printf("\nVariables:\n")
|
fmt.Printf("\nVariables:\n")
|
||||||
@@ -458,7 +463,7 @@ Example:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clone the subgraph
|
// Clone the subgraph
|
||||||
result, err := cloneSubgraph(ctx, store, subgraph, vars, actor)
|
result, err := cloneSubgraph(ctx, store, subgraph, vars, assignee, actor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -484,6 +489,7 @@ func init() {
|
|||||||
|
|
||||||
templateInstantiateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
|
templateInstantiateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
|
||||||
templateInstantiateCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
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(templateListCmd)
|
||||||
templateCmd.AddCommand(templateShowCmd)
|
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
|
// 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 {
|
if s == nil {
|
||||||
return nil, fmt.Errorf("no database connection")
|
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 {
|
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
// First pass: create all issues with new IDs
|
// First pass: create all issues with new IDs
|
||||||
for _, oldIssue := range subgraph.Issues {
|
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{
|
newIssue := &types.Issue{
|
||||||
// Don't set ID - let the system generate it
|
// Don't set ID - let the system generate it
|
||||||
Title: substituteVariables(oldIssue.Title, vars),
|
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
|
Status: types.StatusOpen, // Always start fresh
|
||||||
Priority: oldIssue.Priority,
|
Priority: oldIssue.Priority,
|
||||||
IssueType: oldIssue.IssueType,
|
IssueType: oldIssue.IssueType,
|
||||||
Assignee: oldIssue.Assignee,
|
Assignee: issueAssignee,
|
||||||
EstimatedMinutes: oldIssue.EstimatedMinutes,
|
EstimatedMinutes: oldIssue.EstimatedMinutes,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ func TestCloneSubgraph(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vars := map[string]string{"version": "2.0.0"}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -490,7 +490,7 @@ func TestCloneSubgraph(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vars := map[string]string{"service": "api-gateway"}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -549,7 +549,7 @@ func TestCloneSubgraph(t *testing.T) {
|
|||||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
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.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
|
// TestExtractAllVariables tests extracting variables from entire subgraph
|
||||||
|
|||||||
Reference in New Issue
Block a user