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:
Steve Yegge
2025-12-17 23:38:19 -08:00
parent 003a7d98db
commit a68c52a536
4 changed files with 67 additions and 123 deletions

View File

@@ -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
```

View File

@@ -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")

View File

@@ -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(),

View File

@@ -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