Files
beads/cmd/bd/create.go
Eugene Sukhodolin 4ccdd7a2df fix(routing): close original store before replacing with target store (#1215)
* fix(routing): close original store before replacing with target store

Fix "database is closed" error during auto-flush when contributor routing
redirects issue creation to a different repository (e.g., ~/.beads-planning).

When a user is detected as a contributor (based on git remote URL pattern),
the default configuration routes new issues to ~/.beads-planning instead of
the current repository. This routing caused auto-flush to fail with:

  Warning: auto-flush failed: failed to get stored JSONL hash:
  failed to get jsonl_file_hash: sql: database is closed

In create.go, when switching to the planning repo:
1. targetStore was opened for the planning database
2. A defer was set to close targetStore at function end
3. store = targetStore replaced the global store pointer
4. Issue was created successfully
5. Command Run ended → defer closed targetStore
6. PersistentPostRun tried to flush using the global store → error!

The bug: the defer closed targetStore, but the global `store` variable
pointed to the same object. When PersistentPostRun called flushManager.Shutdown(),
it tried to use the already-closed store for the flush operation.

- Remove the defer that prematurely closed targetStore
- Explicitly close the ORIGINAL store before replacing it (it won't be used)
- Let PersistentPostRun close whatever store is current at exit time

This ensures proper store lifecycle:
- Original store: closed when replaced (no longer needed)
- Target store: closed by PersistentPostRun after flush completes

1. Have a git repo with a remote URL that doesn't match maintainer patterns
   (e.g., github-a:org/repo.git instead of git@github.com:org/repo.git)
2. Have ~/.beads-planning directory with beads initialized
3. Run: cd /path/to/repo && bd create "Test issue"
4. Observe: Issue created successfully, but followed by:
   Warning: auto-flush failed: ... sql: database is closed

Added debug prints to SQLiteStorage.Close() and GetJSONLFileHash() which
revealed two different databases were involved:
- /path/to/repo/.beads/beads.db (closed first, unexpectedly)
- ~/.beads-planning/.beads/beads.db (used for flush after being closed)

* fix(test): resolve race condition in TestAutoFlushOnExit

Not directly related to the previous routing fix, but exposed by CI timing.

Root cause: The test had a latent race condition between two channel operations:
1. markDirtyAndScheduleFlush() sends event to markDirtyCh
2. Shutdown() sends request to shutdownCh

Without any delay, Go's scheduler might process the shutdown event first.
Since isDirty was still false (markDirty event not yet processed), the
FlushManager would skip the flush entirely, causing the test to fail with
"Expected JSONL file to be created on exit".

The race was always present but only manifested in CI environments with
different timing characteristics (CPU contention, virtualization, scheduler
behavior).

Fix: Add a 10ms sleep after markDirtyAndScheduleFlush() to allow the
FlushManager's background goroutine to process the event before shutdown.
This mimics real-world behavior where there's always some time between
CRUD operations and process exit.
2026-01-20 14:05:40 -08:00

1007 lines
35 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/hooks"
"github.com/steveyegge/beads/internal/routing"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/factory"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/timeparsing"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/validation"
)
var createCmd = &cobra.Command{
Use: "create [title]",
GroupID: "issues",
Aliases: []string{"new"},
Short: "Create a new issue (or multiple issues from markdown file)",
Args: cobra.MinimumNArgs(0), // Changed to allow no args when using -f
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("create")
file, _ := cmd.Flags().GetString("file")
// If file flag is provided, parse markdown and create multiple issues
if file != "" {
if len(args) > 0 {
FatalError("cannot specify both title and --file flag")
}
// --dry-run not supported with --file (would need to parse and preview multiple issues)
dryRun, _ := cmd.Flags().GetBool("dry-run")
if dryRun {
FatalError("--dry-run is not supported with --file flag")
}
createIssuesFromMarkdown(cmd, file)
return
}
// Original single-issue creation logic
// Get title from flag or positional argument
titleFlag, _ := cmd.Flags().GetString("title")
var title string
if len(args) > 0 && titleFlag != "" {
// Both provided - check if they match
if args[0] != titleFlag {
FatalError("cannot specify different titles as both positional argument and --title flag\n Positional: %q\n --title: %q", args[0], titleFlag)
}
title = args[0] // They're the same, use either
} else if len(args) > 0 {
title = args[0]
} else if titleFlag != "" {
title = titleFlag
} else {
FatalError("title required (or use --file to create from markdown)")
}
// Get silent flag
silent, _ := cmd.Flags().GetBool("silent")
// Warn if creating a test issue in production database (unless silent mode)
if strings.HasPrefix(strings.ToLower(title), "test") && !silent && !debug.IsQuiet() {
fmt.Fprintf(os.Stderr, "%s Creating issue with 'Test' prefix in production database.\n", ui.RenderWarn("⚠"))
fmt.Fprintf(os.Stderr, " For testing, consider using: BEADS_DB=/tmp/test.db ./bd create \"Test issue\"\n")
}
// Get field values
description, _ := getDescriptionFlag(cmd)
// Check if description is required by config
if description == "" && !strings.Contains(strings.ToLower(title), "test") {
if config.GetBool("create.require-description") {
FatalError("description is required (set create.require-description: false in config.yaml to disable)")
}
// Warn if creating an issue without a description (unless silent mode)
if !silent && !debug.IsQuiet() {
fmt.Fprintf(os.Stderr, "%s Creating issue without description.\n", ui.RenderWarn("⚠"))
fmt.Fprintf(os.Stderr, " Issues without descriptions lack context for future work.\n")
fmt.Fprintf(os.Stderr, " Consider adding --description=\"Why this issue exists and what needs to be done\"\n")
}
}
design, _ := cmd.Flags().GetString("design")
acceptance, _ := cmd.Flags().GetString("acceptance")
notes, _ := cmd.Flags().GetString("notes")
// Parse priority (supports both "1" and "P1" formats)
priorityStr, _ := cmd.Flags().GetString("priority")
priority, err := validation.ValidatePriority(priorityStr)
if err != nil {
FatalError("%v", err)
}
issueType, _ := cmd.Flags().GetString("type")
assignee, _ := cmd.Flags().GetString("assignee")
labels, _ := cmd.Flags().GetStringSlice("labels")
labelAlias, _ := cmd.Flags().GetStringSlice("label")
if len(labelAlias) > 0 {
labels = append(labels, labelAlias...)
}
explicitID, _ := cmd.Flags().GetString("id")
parentID, _ := cmd.Flags().GetString("parent")
externalRef, _ := cmd.Flags().GetString("external-ref")
deps, _ := cmd.Flags().GetStringSlice("deps")
waitsFor, _ := cmd.Flags().GetString("waits-for")
waitsForGate, _ := cmd.Flags().GetString("waits-for-gate")
forceCreate, _ := cmd.Flags().GetBool("force")
repoOverride, _ := cmd.Flags().GetString("repo")
rigOverride, _ := cmd.Flags().GetString("rig")
prefixOverride, _ := cmd.Flags().GetString("prefix")
wisp, _ := cmd.Flags().GetBool("ephemeral")
molTypeStr, _ := cmd.Flags().GetString("mol-type")
var molType types.MolType
if molTypeStr != "" {
molType = types.MolType(molTypeStr)
if !molType.IsValid() {
FatalError("invalid mol-type %q (must be swarm, patrol, or work)", molTypeStr)
}
}
// Agent-specific flags
roleType, _ := cmd.Flags().GetString("role-type")
agentRig, _ := cmd.Flags().GetString("agent-rig")
// Validate agent-specific flags require --type=agent
if (roleType != "" || agentRig != "") && issueType != "agent" {
FatalError("--role-type and --agent-rig flags require --type=agent")
}
// Event-specific flags
eventCategory, _ := cmd.Flags().GetString("event-category")
eventActor, _ := cmd.Flags().GetString("event-actor")
eventTarget, _ := cmd.Flags().GetString("event-target")
eventPayload, _ := cmd.Flags().GetString("event-payload")
// Validate event-specific flags require --type=event
if (eventCategory != "" || eventActor != "" || eventTarget != "" || eventPayload != "") && issueType != "event" {
FatalError("--event-category, --event-actor, --event-target, and --event-payload flags require --type=event")
}
// Parse --due flag (GH#820)
// Uses layered parsing: compact duration → NLP → date-only → RFC3339
var dueAt *time.Time
dueStr, _ := cmd.Flags().GetString("due")
if dueStr != "" {
t, err := timeparsing.ParseRelativeTime(dueStr, time.Now())
if err != nil {
FatalError("invalid --due format %q. Examples: +6h, tomorrow, next monday, 2025-01-15", dueStr)
}
dueAt = &t
}
// Parse --defer flag (GH#820)
var deferUntil *time.Time
deferStr, _ := cmd.Flags().GetString("defer")
if deferStr != "" {
t, err := timeparsing.ParseRelativeTime(deferStr, time.Now())
if err != nil {
FatalError("invalid --defer format %q. Examples: +1h, tomorrow, next monday, 2025-01-15", deferStr)
}
// Warn if defer date is in the past (user probably meant future)
if t.Before(time.Now()) && !silent && !debug.IsQuiet() {
fmt.Fprintf(os.Stderr, "%s Defer date %q is in the past. Issue will appear in bd ready immediately.\n",
ui.RenderWarn("!"), t.Format("2006-01-02 15:04"))
fmt.Fprintf(os.Stderr, " Did you mean a future date? Use --defer=+1h or --defer=tomorrow\n")
}
deferUntil = &t
}
// Handle --dry-run flag (before --rig to ensure it works with cross-rig creation)
dryRun, _ := cmd.Flags().GetBool("dry-run")
if dryRun {
// Build preview issue
var externalRefPtr *string
if externalRef != "" {
externalRefPtr = &externalRef
}
previewIssue := &types.Issue{
Title: title,
Description: description,
Design: design,
AcceptanceCriteria: acceptance,
Notes: notes,
Status: types.StatusOpen,
Priority: priority,
IssueType: types.IssueType(issueType).Normalize(),
Assignee: assignee,
ExternalRef: externalRefPtr,
Ephemeral: wisp,
CreatedBy: getActorWithGit(),
Owner: getOwner(),
MolType: molType,
RoleType: roleType,
Rig: agentRig,
DueAt: dueAt,
DeferUntil: deferUntil,
// Event fields
EventKind: eventCategory,
Actor: eventActor,
Target: eventTarget,
Payload: eventPayload,
}
if explicitID != "" {
previewIssue.ID = explicitID
}
if jsonOutput {
outputJSON(previewIssue)
} else {
idDisplay := previewIssue.ID
if idDisplay == "" {
idDisplay = "(will be generated)"
}
fmt.Printf("%s [DRY RUN] Would create issue:\n", ui.RenderWarn("⚠"))
fmt.Printf(" ID: %s\n", idDisplay)
fmt.Printf(" Title: %s\n", previewIssue.Title)
fmt.Printf(" Type: %s\n", previewIssue.IssueType)
fmt.Printf(" Priority: P%d\n", previewIssue.Priority)
fmt.Printf(" Status: %s\n", previewIssue.Status)
if previewIssue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", previewIssue.Assignee)
}
if previewIssue.Description != "" {
fmt.Printf(" Description: %s\n", previewIssue.Description)
}
if len(labels) > 0 {
fmt.Printf(" Labels: %s\n", strings.Join(labels, ", "))
}
if len(deps) > 0 {
fmt.Printf(" Dependencies: %s\n", strings.Join(deps, ", "))
}
if rigOverride != "" || prefixOverride != "" {
rig := rigOverride
if rig == "" {
rig = prefixOverride
}
fmt.Printf(" Target rig: %s\n", rig)
}
if eventCategory != "" {
fmt.Printf(" Event category: %s\n", eventCategory)
}
}
return
}
// Handle --rig or --prefix flag: create issue in a different rig
// Both flags use the same forgiving lookup (accepts rig names or prefixes)
targetRig := rigOverride
if prefixOverride != "" {
if targetRig != "" {
FatalError("cannot specify both --rig and --prefix flags")
}
targetRig = prefixOverride
}
if targetRig != "" {
createInRig(cmd, targetRig, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, wisp)
return
}
// Get estimate if provided
var estimatedMinutes *int
if cmd.Flags().Changed("estimate") {
est, _ := cmd.Flags().GetInt("estimate")
if est < 0 {
FatalError("estimate must be a non-negative number of minutes")
}
estimatedMinutes = &est
}
// Validate template based on --validate flag or config
validateTemplate, _ := cmd.Flags().GetBool("validate")
if validateTemplate {
// Explicit --validate flag: fail on error
if err := validation.ValidateTemplate(types.IssueType(issueType), description); err != nil {
FatalError("%v", err)
}
} else {
// Check validation.on-create config (bd-t7jq)
validationMode := config.GetString("validation.on-create")
if validationMode == "error" || validationMode == "warn" {
if err := validation.ValidateTemplate(types.IssueType(issueType), description); err != nil {
if validationMode == "error" {
FatalError("%v", err)
} else {
// warn mode: print warning but proceed
fmt.Fprintf(os.Stderr, "%s %v\n", ui.RenderWarn("⚠"), err)
}
}
}
}
// Use global jsonOutput set by PersistentPreRun
// Determine target repository using routing logic
repoPath := "." // default to current directory
if cmd.Flags().Changed("repo") {
// Explicit --repo flag overrides auto-routing
repoPath = repoOverride
} else {
// Auto-routing based on user role
userRole, err := routing.DetectUserRole(".")
if err != nil {
debug.Logf("Warning: failed to detect user role: %v\n", err)
}
// Build routing config with backward compatibility for legacy contributor.* keys
routingMode := config.GetString("routing.mode")
contributorRepo := config.GetString("routing.contributor")
// NFR-001: Backward compatibility - fall back to legacy contributor.* keys
if routingMode == "" {
if config.GetString("contributor.auto_route") == "true" {
routingMode = "auto"
}
}
if contributorRepo == "" {
contributorRepo = config.GetString("contributor.planning_repo")
}
routingConfig := &routing.RoutingConfig{
Mode: routingMode,
DefaultRepo: config.GetString("routing.default"),
MaintainerRepo: config.GetString("routing.maintainer"),
ContributorRepo: contributorRepo,
ExplicitOverride: repoOverride,
}
repoPath = routing.DetermineTargetRepo(routingConfig, userRole, ".")
}
// Switch to target repo for multi-repo support (bd-6x6g)
// When routing to a different repo, we bypass daemon mode and use direct storage
var targetStore storage.Storage
if repoPath != "." {
targetBeadsDir := routing.ExpandPath(repoPath)
debug.Logf("DEBUG: Routing to target repo: %s\n", targetBeadsDir)
// Ensure target beads directory exists with prefix inheritance
if err := ensureBeadsDirForPath(rootCtx, targetBeadsDir, store); err != nil {
FatalError("failed to initialize target repo: %v", err)
}
// Open new store for target repo using factory to respect backend config
targetBeadsDirPath := filepath.Join(targetBeadsDir, ".beads")
var err error
targetStore, err = factory.NewFromConfig(rootCtx, targetBeadsDirPath)
if err != nil {
FatalError("failed to open target store: %v", err)
}
// Close the original store before replacing it (it won't be used anymore)
// Note: We don't defer-close targetStore here because PersistentPostRun
// will close whatever store is assigned to the global `store` variable.
// This fixes the "database is closed" error during auto-flush (GH#routing-close-bug).
if store != nil {
_ = store.Close()
}
// Replace store for remainder of create operation
// This also bypasses daemon mode since daemon owns the current repo's store
store = targetStore
daemonClient = nil // Bypass daemon for routed issues (T013)
}
// Check for conflicting flags
if explicitID != "" && parentID != "" {
FatalError("cannot specify both --id and --parent flags")
}
// If parent is specified, generate child ID
// In daemon mode, the parent will be sent to the RPC handler
// In direct mode, we generate the child ID here
if parentID != "" && daemonClient == nil {
ctx := rootCtx
// Validate parent exists before generating child ID
parentIssue, err := store.GetIssue(ctx, parentID)
if err != nil {
FatalError("failed to check parent issue: %v", err)
}
if parentIssue == nil {
FatalError("parent issue %s not found", parentID)
}
childID, err := store.GetNextChildID(ctx, parentID)
if err != nil {
FatalError("%v", err)
}
explicitID = childID // Set as explicit ID for the rest of the flow
}
// Validate explicit ID format if provided
if explicitID != "" {
// For agent types, use agent-aware validation.
// This fixes the bug where 3-char polecat names like "nux" in
// "nx-nexus-polecat-nux" were incorrectly treated as hash suffixes.
if issueType == "agent" {
if err := validation.ValidateAgentID(explicitID); err != nil {
FatalError("invalid agent ID: %v", err)
}
} else {
_, err := validation.ValidateIDFormat(explicitID)
if err != nil {
FatalError("%v", err)
}
}
// Validate prefix matches database prefix
ctx := rootCtx
// Get database prefix and allowed prefixes from config
var dbPrefix, allowedPrefixes string
if daemonClient != nil {
// Daemon mode - use RPC to get config
configResp, err := daemonClient.GetConfig(&rpc.GetConfigArgs{Key: "issue_prefix"})
if err == nil {
dbPrefix = configResp.Value
}
// Also get allowed_prefixes for multi-prefix support (e.g., Gas Town)
allowedResp, err := daemonClient.GetConfig(&rpc.GetConfigArgs{Key: "allowed_prefixes"})
if err == nil {
allowedPrefixes = allowedResp.Value
}
// If error, continue without validation (non-fatal)
} else {
// Direct mode - check config (GH#1145: fallback to config.yaml)
dbPrefix, _ = store.GetConfig(ctx, "issue_prefix")
if dbPrefix == "" {
dbPrefix = config.GetString("issue-prefix")
}
allowedPrefixes, _ = store.GetConfig(ctx, "allowed_prefixes")
}
// Use ValidateIDPrefixAllowed which handles multi-hyphen prefixes correctly (GH#1135)
// This checks if the ID starts with an allowed prefix, rather than extracting
// the prefix first (which can fail for IDs like "hq-cv-test" where "test" looks like a word)
if err := validation.ValidateIDPrefixAllowed(explicitID, dbPrefix, allowedPrefixes, forceCreate); err != nil {
FatalError("%v", err)
}
}
var externalRefPtr *string
if externalRef != "" {
externalRefPtr = &externalRef
}
// If daemon is running, use RPC
if daemonClient != nil {
createArgs := &rpc.CreateArgs{
ID: explicitID,
Parent: parentID,
Title: title,
Description: description,
IssueType: issueType,
Priority: priority,
Design: design,
AcceptanceCriteria: acceptance,
Notes: notes,
Assignee: assignee,
ExternalRef: externalRef,
EstimatedMinutes: estimatedMinutes,
Labels: labels,
Dependencies: deps,
WaitsFor: waitsFor,
WaitsForGate: waitsForGate,
Ephemeral: wisp,
CreatedBy: getActorWithGit(),
Owner: getOwner(),
MolType: string(molType),
RoleType: roleType,
Rig: agentRig,
EventCategory: eventCategory,
EventActor: eventActor,
EventTarget: eventTarget,
EventPayload: eventPayload,
DueAt: formatTimeForRPC(dueAt),
DeferUntil: formatTimeForRPC(deferUntil),
}
resp, err := daemonClient.Create(createArgs)
if err != nil {
FatalError("%v", err)
}
// Parse response to get issue for hook
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err != nil {
FatalError("parsing response: %v", err)
}
// Run create hook
if hookRunner != nil {
hookRunner.Run(hooks.EventCreate, &issue)
}
if jsonOutput {
fmt.Println(string(resp.Data))
} else if silent {
fmt.Println(issue.ID)
} else {
fmt.Printf("%s Created issue: %s\n", ui.RenderPass("✓"), issue.ID)
fmt.Printf(" Title: %s\n", issue.Title)
fmt.Printf(" Priority: P%d\n", issue.Priority)
fmt.Printf(" Status: %s\n", issue.Status)
}
// Track as last touched issue
SetLastTouchedID(issue.ID)
return
}
// Direct mode
issue := &types.Issue{
ID: explicitID, // Set explicit ID if provided (empty string if not)
Title: title,
Description: description,
Design: design,
AcceptanceCriteria: acceptance,
Notes: notes,
Status: types.StatusOpen,
Priority: priority,
IssueType: types.IssueType(issueType).Normalize(),
Assignee: assignee,
ExternalRef: externalRefPtr,
EstimatedMinutes: estimatedMinutes,
Ephemeral: wisp,
CreatedBy: getActorWithGit(),
Owner: getOwner(),
MolType: molType,
RoleType: roleType,
Rig: agentRig,
EventKind: eventCategory,
Actor: eventActor,
Target: eventTarget,
Payload: eventPayload,
DueAt: dueAt,
DeferUntil: deferUntil,
}
ctx := rootCtx
// Check if any dependencies are discovered-from type
// If so, inherit source_repo from the parent issue
var discoveredFromParentID string
for _, depSpec := range deps {
depSpec = strings.TrimSpace(depSpec)
if depSpec == "" {
continue
}
var depType types.DependencyType
var dependsOnID string
if strings.Contains(depSpec, ":") {
parts := strings.SplitN(depSpec, ":", 2)
if len(parts) == 2 {
depType = types.DependencyType(strings.TrimSpace(parts[0]))
dependsOnID = strings.TrimSpace(parts[1])
if depType == types.DepDiscoveredFrom && dependsOnID != "" {
discoveredFromParentID = dependsOnID
break
}
}
}
}
// If we found a discovered-from dependency, inherit source_repo from parent
if discoveredFromParentID != "" {
parentIssue, err := store.GetIssue(ctx, discoveredFromParentID)
if err == nil && parentIssue.SourceRepo != "" {
issue.SourceRepo = parentIssue.SourceRepo
}
// If error getting parent or parent has no source_repo, continue with default
}
if err := store.CreateIssue(ctx, issue, actor); err != nil {
FatalError("%v", err)
}
// If parent was specified, add parent-child dependency
if parentID != "" {
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: parentID,
Type: types.DepParentChild,
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
WarnError("failed to add parent-child dependency %s -> %s: %v", issue.ID, parentID, err)
}
}
// Add labels if specified
for _, label := range labels {
if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil {
WarnError("failed to add label %s: %v", label, err)
}
}
// Auto-add role_type/rig labels for agent beads (enables filtering queries)
// Check for gt:agent label to identify agent beads (Gas Town separation)
hasAgentLabel := false
for _, l := range labels {
if l == "gt:agent" {
hasAgentLabel = true
break
}
}
if hasAgentLabel {
if issue.RoleType != "" {
agentLabel := "role_type:" + issue.RoleType
if err := store.AddLabel(ctx, issue.ID, agentLabel, actor); err != nil {
WarnError("failed to add role_type label: %v", err)
}
}
if issue.Rig != "" {
rigLabel := "rig:" + issue.Rig
if err := store.AddLabel(ctx, issue.ID, rigLabel, actor); err != nil {
WarnError("failed to add rig label: %v", err)
}
}
}
// Add dependencies if specified (format: type:id or just id for default "blocks" type)
for _, depSpec := range deps {
// Skip empty specs (e.g., from trailing commas)
depSpec = strings.TrimSpace(depSpec)
if depSpec == "" {
continue
}
var depType types.DependencyType
var dependsOnID string
// Parse format: "type:id" or just "id" (defaults to "blocks")
if strings.Contains(depSpec, ":") {
parts := strings.SplitN(depSpec, ":", 2)
if len(parts) != 2 {
WarnError("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec)
continue
}
depType = types.DependencyType(strings.TrimSpace(parts[0]))
dependsOnID = strings.TrimSpace(parts[1])
} else {
// Default to "blocks" if no type specified
depType = types.DepBlocks
dependsOnID = depSpec
}
// Validate dependency type
if !depType.IsValid() {
WarnError("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType)
continue
}
// Add the dependency
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: dependsOnID,
Type: depType,
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
WarnError("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err)
}
}
// Add waits-for dependency if specified
if waitsFor != "" {
// Validate gate type
gate := waitsForGate
if gate == "" {
gate = types.WaitsForAllChildren
}
if gate != types.WaitsForAllChildren && gate != types.WaitsForAnyChildren {
FatalError("invalid --waits-for-gate value '%s' (valid: all-children, any-children)", gate)
}
// Create metadata JSON
meta := types.WaitsForMeta{
Gate: gate,
}
metaJSON, err := json.Marshal(meta)
if err != nil {
FatalError("failed to serialize waits-for metadata: %v", err)
}
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: waitsFor,
Type: types.DepWaitsFor,
Metadata: string(metaJSON),
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
WarnError("failed to add waits-for dependency %s -> %s: %v", issue.ID, waitsFor, err)
}
}
// Schedule auto-flush
markDirtyAndScheduleFlush()
// Run create hook
if hookRunner != nil {
hookRunner.Run(hooks.EventCreate, issue)
}
if jsonOutput {
outputJSON(issue)
} else if silent {
fmt.Println(issue.ID)
} else {
fmt.Printf("%s Created issue: %s\n", ui.RenderPass("✓"), issue.ID)
fmt.Printf(" Title: %s\n", issue.Title)
fmt.Printf(" Priority: P%d\n", issue.Priority)
fmt.Printf(" Status: %s\n", issue.Status)
// Show tip after successful create (direct mode only)
maybeShowTip(store)
}
// Track as last touched issue
SetLastTouchedID(issue.ID)
},
}
func init() {
createCmd.Flags().StringP("file", "f", "", "Create multiple issues from markdown file")
createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)")
createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)")
createCmd.Flags().Bool("dry-run", false, "Preview what would be created without actually creating")
registerPriorityFlag(createCmd, "2")
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role|rig|convoy|event); enhancement is alias for feature")
registerCommonIssueFlags(createCmd)
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels")
_ = createCmd.Flags().MarkHidden("label") // Only fails if flag missing (caught in tests)
createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)")
createCmd.Flags().String("parent", "", "Parent issue ID for hierarchical child (e.g., 'bd-a3f8e9')")
createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')")
createCmd.Flags().String("waits-for", "", "Spawner issue ID to wait for (creates waits-for dependency for fanout gate)")
createCmd.Flags().String("waits-for-gate", "all-children", "Gate type: all-children (wait for all) or any-children (wait for first)")
createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix")
createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)")
createCmd.Flags().String("rig", "", "Create issue in a different rig (e.g., --rig beads)")
createCmd.Flags().String("prefix", "", "Create issue in rig by prefix (e.g., --prefix bd- or --prefix bd or --prefix beads)")
createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)")
createCmd.Flags().Bool("ephemeral", false, "Create as ephemeral (ephemeral, not exported to JSONL)")
createCmd.Flags().String("mol-type", "", "Molecule type: swarm (multi-polecat), patrol (recurring ops), work (default)")
createCmd.Flags().Bool("validate", false, "Validate description contains required sections for issue type")
// Agent-specific flags (only valid when --type=agent)
createCmd.Flags().String("role-type", "", "Agent role type: polecat|crew|witness|refinery|mayor|deacon (requires --type=agent)")
createCmd.Flags().String("agent-rig", "", "Agent's rig name (requires --type=agent)")
// Event-specific flags (only valid when --type=event)
createCmd.Flags().String("event-category", "", "Event category (e.g., patrol.muted, agent.started) (requires --type=event)")
createCmd.Flags().String("event-actor", "", "Entity URI who caused this event (requires --type=event)")
createCmd.Flags().String("event-target", "", "Entity URI or bead ID affected (requires --type=event)")
createCmd.Flags().String("event-payload", "", "Event-specific JSON data (requires --type=event)")
// Time-based scheduling flags (GH#820)
// Examples:
// --due=+6h Due in 6 hours
// --due=tomorrow Due tomorrow
// --due="next monday" Due next Monday
// --due=2025-01-15 Due on specific date
// --defer=+1h Hidden from bd ready for 1 hour
// --defer=tomorrow Hidden until tomorrow
createCmd.Flags().String("due", "", "Due date/time. Formats: +6h, +1d, +2w, tomorrow, next monday, 2025-01-15")
createCmd.Flags().String("defer", "", "Defer until date (issue hidden from bd ready until then). Same formats as --due")
// Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(createCmd)
}
// createInRig creates an issue in a different rig using --rig flag.
// This bypasses the normal daemon/direct flow and directly creates in the target rig.
func createInRig(cmd *cobra.Command, rigName, title, description, issueType string, priority int, design, acceptance, notes, assignee string, labels []string, externalRef string, wisp bool) {
ctx := rootCtx
// Find the town-level beads directory (where routes.jsonl lives)
townBeadsDir, err := findTownBeadsDir()
if err != nil {
FatalError("cannot use --rig: %v", err)
}
// Resolve the target rig's beads directory and prefix
targetBeadsDir, targetPrefix, err := routing.ResolveBeadsDirForRig(rigName, townBeadsDir)
if err != nil {
FatalError("%v", err)
}
// Open storage for the target rig using factory to respect backend config
targetStore, err := factory.NewFromConfig(ctx, targetBeadsDir)
if err != nil {
FatalError("failed to open rig %q database: %v", rigName, err)
}
defer func() {
if err := targetStore.Close(); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to close rig database: %v\n", err)
}
}()
// Prepare prefix override from routes.jsonl for cross-rig creation
// Strip trailing hyphen - database stores prefix without it (e.g., "aops" not "aops-")
var prefixOverride string
if targetPrefix != "" {
prefixOverride = strings.TrimSuffix(targetPrefix, "-")
}
var externalRefPtr *string
if externalRef != "" {
externalRefPtr = &externalRef
}
// Extract event-specific flags (bd-xwvo fix)
eventCategory, _ := cmd.Flags().GetString("event-category")
eventActor, _ := cmd.Flags().GetString("event-actor")
eventTarget, _ := cmd.Flags().GetString("event-target")
eventPayload, _ := cmd.Flags().GetString("event-payload")
// Extract molecule/agent flags (bd-xwvo fix)
molTypeStr, _ := cmd.Flags().GetString("mol-type")
var molType types.MolType
if molTypeStr != "" {
molType = types.MolType(molTypeStr)
}
roleType, _ := cmd.Flags().GetString("role-type")
agentRig, _ := cmd.Flags().GetString("agent-rig")
// Extract time-based scheduling flags (bd-xwvo fix)
var dueAt *time.Time
dueStr, _ := cmd.Flags().GetString("due")
if dueStr != "" {
t, err := timeparsing.ParseRelativeTime(dueStr, time.Now())
if err != nil {
FatalError("invalid --due format %q", dueStr)
}
dueAt = &t
}
var deferUntil *time.Time
deferStr, _ := cmd.Flags().GetString("defer")
if deferStr != "" {
t, err := timeparsing.ParseRelativeTime(deferStr, time.Now())
if err != nil {
FatalError("invalid --defer format %q", deferStr)
}
deferUntil = &t
}
// Create issue without ID - CreateIssue will generate one with the correct prefix
issue := &types.Issue{
Title: title,
Description: description,
Design: design,
AcceptanceCriteria: acceptance,
Notes: notes,
Status: types.StatusOpen,
Priority: priority,
IssueType: types.IssueType(issueType).Normalize(),
Assignee: assignee,
ExternalRef: externalRefPtr,
Ephemeral: wisp,
CreatedBy: getActorWithGit(),
Owner: getOwner(),
// Event fields (bd-xwvo fix)
EventKind: eventCategory,
Actor: eventActor,
Target: eventTarget,
Payload: eventPayload,
// Molecule/agent fields (bd-xwvo fix)
MolType: molType,
RoleType: roleType,
Rig: agentRig,
// Time scheduling fields (bd-xwvo fix)
DueAt: dueAt,
DeferUntil: deferUntil,
// Cross-rig routing: use route prefix instead of database config
PrefixOverride: prefixOverride,
}
if err := targetStore.CreateIssue(ctx, issue, actor); err != nil {
FatalError("failed to create issue in rig %q: %v", rigName, err)
}
// Add labels if specified
for _, label := range labels {
if err := targetStore.AddLabel(ctx, issue.ID, label, actor); err != nil {
WarnError("failed to add label %s: %v", label, err)
}
}
// Get silent flag
silent, _ := cmd.Flags().GetBool("silent")
if jsonOutput {
outputJSON(issue)
} else if silent {
fmt.Println(issue.ID)
} else {
fmt.Printf("%s Created issue in rig %q: %s\n", ui.RenderPass("✓"), rigName, issue.ID)
fmt.Printf(" Title: %s\n", issue.Title)
fmt.Printf(" Priority: P%d\n", issue.Priority)
fmt.Printf(" Status: %s\n", issue.Status)
}
}
// findTownBeadsDir finds the town-level .beads directory (where routes.jsonl lives).
// It walks up from the current directory looking for a .beads directory with routes.jsonl.
func findTownBeadsDir() (string, error) {
// Start from current directory and walk up
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
beadsDir := filepath.Join(dir, ".beads")
routesFile := filepath.Join(beadsDir, routing.RoutesFileName)
// Check if this .beads directory has routes.jsonl
if _, err := os.Stat(routesFile); err == nil {
return beadsDir, nil
}
// Move up one directory
parent := filepath.Dir(dir)
if parent == dir {
// Reached filesystem root
break
}
dir = parent
}
return "", fmt.Errorf("no routes.jsonl found in any parent .beads directory")
}
// formatTimeForRPC converts a *time.Time to RFC3339 string for daemon RPC calls.
// Returns empty string if t is nil, allowing the daemon to distinguish "not set" from "set to zero".
func formatTimeForRPC(t *time.Time) string {
if t == nil {
return ""
}
return t.Format(time.RFC3339)
}
// ensureBeadsDirForPath ensures a beads directory exists at the target path.
// If the .beads directory doesn't exist, it creates it and initializes with
// the same prefix as the source store (T010, T012: prefix inheritance).
func ensureBeadsDirForPath(ctx context.Context, targetPath string, sourceStore storage.Storage) error {
beadsDir := filepath.Join(targetPath, ".beads")
dbPath := filepath.Join(beadsDir, "beads.db")
// Check if beads directory already exists
if _, err := os.Stat(beadsDir); err == nil {
// Directory exists, check if database exists
if _, err := os.Stat(dbPath); err == nil {
// Database exists, nothing to do
return nil
}
}
// Create .beads directory
if err := os.MkdirAll(beadsDir, 0750); err != nil {
return fmt.Errorf("cannot create .beads directory: %w", err)
}
// Create issues.jsonl if it doesn't exist
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
// #nosec G306 -- planning repo JSONL must be shareable across collaborators
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create issues.jsonl: %w", err)
}
}
// Initialize database - it will be created when sqlite.New is called
// But we need to set the prefix if source store has one (T012: prefix inheritance)
if sourceStore != nil {
sourcePrefix, err := sourceStore.GetConfig(ctx, "issue_prefix")
if err == nil && sourcePrefix != "" {
// Open target store temporarily to set prefix
tempStore, err := sqlite.New(ctx, dbPath)
if err != nil {
return fmt.Errorf("failed to initialize target database: %w", err)
}
if err := tempStore.SetConfig(ctx, "issue_prefix", sourcePrefix); err != nil {
_ = tempStore.Close()
return fmt.Errorf("failed to set prefix in target store: %w", err)
}
if err := tempStore.Close(); err != nil {
return fmt.Errorf("failed to close target store: %w", err)
}
}
}
return nil
}