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 }