diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 3f203bcf..3042fbd1 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -10,6 +10,7 @@ import ( "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/types" @@ -250,19 +251,22 @@ var createCmd = &cobra.Command{ 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 (bd-kwro.8) + if hookRunner != nil { + hookRunner.Run(hooks.EventCreate, &issue) + } + if jsonOutput { fmt.Println(string(resp.Data)) } else if silent { - var issue types.Issue - if err := json.Unmarshal(resp.Data, &issue); err != nil { - FatalError("parsing response: %v", err) - } fmt.Println(issue.ID) } else { - var issue types.Issue - if err := json.Unmarshal(resp.Data, &issue); err != nil { - FatalError("parsing response: %v", err) - } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID) fmt.Printf(" Title: %s\n", issue.Title) @@ -393,6 +397,11 @@ var createCmd = &cobra.Command{ // Schedule auto-flush markDirtyAndScheduleFlush() + // Run create hook (bd-kwro.8) + if hookRunner != nil { + hookRunner.Run(hooks.EventCreate, issue) + } + if jsonOutput { outputJSON(issue) } else if silent { diff --git a/cmd/bd/duplicate.go b/cmd/bd/duplicate.go new file mode 100644 index 00000000..ea4f0d6a --- /dev/null +++ b/cmd/bd/duplicate.go @@ -0,0 +1,230 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" +) + +var duplicateCmd = &cobra.Command{ + Use: "duplicate --of ", + Short: "Mark an issue as a duplicate of another", + Long: `Mark an issue as a duplicate of a canonical issue. + +The duplicate issue is automatically closed with a reference to the canonical. +This is essential for large issue databases with many similar reports. + +Examples: + bd duplicate bd-abc --of bd-xyz # Mark bd-abc as duplicate of bd-xyz`, + Args: cobra.ExactArgs(1), + RunE: runDuplicate, +} + +var supersedeCmd = &cobra.Command{ + Use: "supersede --with ", + Short: "Mark an issue as superseded by a newer one", + Long: `Mark an issue as superseded by a newer version. + +The superseded issue is automatically closed with a reference to the replacement. +Useful for design docs, specs, and evolving artifacts. + +Examples: + bd supersede bd-old --with bd-new # Mark bd-old as superseded by bd-new`, + Args: cobra.ExactArgs(1), + RunE: runSupersede, +} + +var ( + duplicateOf string + supersededWith string +) + +func init() { + duplicateCmd.Flags().StringVar(&duplicateOf, "of", "", "Canonical issue ID (required)") + _ = duplicateCmd.MarkFlagRequired("of") + rootCmd.AddCommand(duplicateCmd) + + supersedeCmd.Flags().StringVar(&supersededWith, "with", "", "Replacement issue ID (required)") + _ = supersedeCmd.MarkFlagRequired("with") + rootCmd.AddCommand(supersedeCmd) +} + +func runDuplicate(cmd *cobra.Command, args []string) error { + CheckReadonly("duplicate") + + ctx := rootCtx + + // Resolve partial IDs + var duplicateID, canonicalID string + if daemonClient != nil { + resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + if err := json.Unmarshal(resp1.Data, &duplicateID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: duplicateOf}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", duplicateOf, err) + } + if err := json.Unmarshal(resp2.Data, &canonicalID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + duplicateID, err = utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + canonicalID, err = utils.ResolvePartialID(ctx, store, duplicateOf) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", duplicateOf, err) + } + } + + if duplicateID == canonicalID { + return fmt.Errorf("cannot mark an issue as duplicate of itself") + } + + // Verify canonical issue exists + var canonical *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: canonicalID}) + if err != nil { + return fmt.Errorf("canonical issue not found: %s", canonicalID) + } + if err := json.Unmarshal(resp.Data, &canonical); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + canonical, err = store.GetIssue(ctx, canonicalID) + if err != nil || canonical == nil { + return fmt.Errorf("canonical issue not found: %s", canonicalID) + } + } + + // Update the duplicate issue with duplicate_of and close it + updates := map[string]interface{}{ + "duplicate_of": canonicalID, + "status": string(types.StatusClosed), + } + + if err := store.UpdateIssue(ctx, duplicateID, updates, actor); err != nil { + return fmt.Errorf("failed to mark as duplicate: %w", err) + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "duplicate": duplicateID, + "canonical": canonicalID, + "status": "closed", + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Marked %s as duplicate of %s (closed)\n", green("✓"), duplicateID, canonicalID) + return nil +} + +func runSupersede(cmd *cobra.Command, args []string) error { + CheckReadonly("supersede") + + ctx := rootCtx + + // Resolve partial IDs + var oldID, newID string + if daemonClient != nil { + resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + if err := json.Unmarshal(resp1.Data, &oldID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: supersededWith}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", supersededWith, err) + } + if err := json.Unmarshal(resp2.Data, &newID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + oldID, err = utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + newID, err = utils.ResolvePartialID(ctx, store, supersededWith) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", supersededWith, err) + } + } + + if oldID == newID { + return fmt.Errorf("cannot mark an issue as superseded by itself") + } + + // Verify new issue exists + var newIssue *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: newID}) + if err != nil { + return fmt.Errorf("replacement issue not found: %s", newID) + } + if err := json.Unmarshal(resp.Data, &newIssue); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + newIssue, err = store.GetIssue(ctx, newID) + if err != nil || newIssue == nil { + return fmt.Errorf("replacement issue not found: %s", newID) + } + } + + // Update the old issue with superseded_by and close it + updates := map[string]interface{}{ + "superseded_by": newID, + "status": string(types.StatusClosed), + } + + if err := store.UpdateIssue(ctx, oldID, updates, actor); err != nil { + return fmt.Errorf("failed to mark as superseded: %w", err) + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "superseded": oldID, + "replacement": newID, + "status": "closed", + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Marked %s as superseded by %s (closed)\n", green("✓"), oldID, newID) + return nil +} diff --git a/cmd/bd/mail.go b/cmd/bd/mail.go index b3bfa2d3..90f9fc42 100644 --- a/cmd/bd/mail.go +++ b/cmd/bd/mail.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/hooks" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" ) @@ -85,6 +86,21 @@ Examples: RunE: runMailAck, } +var mailReplyCmd = &cobra.Command{ + Use: "reply -m ", + Short: "Reply to a message", + Long: `Reply to an existing message, creating a conversation thread. + +Creates a new message with replies_to set to the original message, +and sends it to the original sender. + +Examples: + bd mail reply bd-abc123 -m "Thanks for the update!" + bd mail reply bd-abc123 -m "Done" --urgent`, + Args: cobra.ExactArgs(1), + RunE: runMailReply, +} + // Mail command flags var ( mailSubject string @@ -101,6 +117,7 @@ func init() { mailCmd.AddCommand(mailInboxCmd) mailCmd.AddCommand(mailReadCmd) mailCmd.AddCommand(mailAckCmd) + mailCmd.AddCommand(mailReplyCmd) // Send command flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") @@ -119,6 +136,12 @@ func init() { // Ack command flags mailAckCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity") + + // Reply command flags + mailReplyCmd.Flags().StringVarP(&mailBody, "body", "m", "", "Reply body (required)") + mailReplyCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)") + mailReplyCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override sender identity") + _ = mailReplyCmd.MarkFlagRequired("body") } func runMailSend(cmd *cobra.Command, args []string) error { @@ -141,6 +164,8 @@ func runMailSend(cmd *cobra.Command, args []string) error { IssueType: string(types.TypeMessage), Priority: priority, Assignee: recipient, + Sender: sender, + Ephemeral: true, // Messages can be bulk-deleted } resp, err := daemonClient.Create(createArgs) @@ -148,13 +173,17 @@ func runMailSend(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to send message: %w", err) } - // Parse response to get issue ID and update sender field + // Parse response to get issue ID var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err != nil { return fmt.Errorf("parsing response: %w", err) } - // Note: sender/ephemeral fields need daemon support - for now they work in direct mode + // Run message hook (bd-kwro.8) + if hookRunner != nil { + hookRunner.Run(hooks.EventMessage, &issue) + } + if jsonOutput { result := map[string]interface{}{ "id": issue.ID, @@ -202,6 +231,11 @@ func runMailSend(cmd *cobra.Command, args []string) error { flushManager.MarkDirty(false) } + // Run message hook (bd-kwro.8) + if hookRunner != nil { + hookRunner.Run(hooks.EventMessage, issue) + } + if jsonOutput { result := map[string]interface{}{ "id": issue.ID, @@ -487,3 +521,152 @@ func runMailAck(cmd *cobra.Command, args []string) error { return nil } + +func runMailReply(cmd *cobra.Command, args []string) error { + CheckReadonly("mail reply") + + messageID := args[0] + sender := config.GetIdentity(mailIdentity) + + // Get the original message + var originalMsg *types.Issue + + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID}) + if err != nil { + return fmt.Errorf("failed to get original message: %w", err) + } + if err := json.Unmarshal(resp.Data, &originalMsg); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + originalMsg, err = store.GetIssue(rootCtx, messageID) + if err != nil { + return fmt.Errorf("failed to get original message: %w", err) + } + } + + if originalMsg == nil { + return fmt.Errorf("message not found: %s", messageID) + } + + if originalMsg.IssueType != types.TypeMessage { + return fmt.Errorf("%s is not a message (type: %s)", messageID, originalMsg.IssueType) + } + + // Determine recipient: reply goes to the original sender + recipient := originalMsg.Sender + if recipient == "" { + return fmt.Errorf("original message has no sender, cannot determine reply recipient") + } + + // Build reply subject + subject := originalMsg.Title + if !strings.HasPrefix(strings.ToLower(subject), "re:") { + subject = "Re: " + subject + } + + // Determine priority + priority := 2 // default: normal + if mailUrgent { + priority = 0 + } + + // Create the reply message + now := time.Now() + reply := &types.Issue{ + Title: subject, + Description: mailBody, + Status: types.StatusOpen, + Priority: priority, + IssueType: types.TypeMessage, + Assignee: recipient, + Sender: sender, + Ephemeral: true, + RepliesTo: messageID, // Thread link + CreatedAt: now, + UpdatedAt: now, + } + + if daemonClient != nil { + // Daemon mode - create reply with all messaging fields + createArgs := &rpc.CreateArgs{ + Title: reply.Title, + Description: reply.Description, + IssueType: string(types.TypeMessage), + Priority: priority, + Assignee: recipient, + Sender: sender, + Ephemeral: true, + RepliesTo: messageID, // Thread link + } + + resp, err := daemonClient.Create(createArgs) + if err != nil { + return fmt.Errorf("failed to send reply: %w", err) + } + + var createdIssue types.Issue + if err := json.Unmarshal(resp.Data, &createdIssue); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + + if jsonOutput { + result := map[string]interface{}{ + "id": createdIssue.ID, + "to": recipient, + "from": sender, + "subject": subject, + "replies_to": messageID, + "priority": priority, + "timestamp": createdIssue.CreatedAt, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("Reply sent: %s\n", createdIssue.ID) + fmt.Printf(" To: %s\n", recipient) + fmt.Printf(" Re: %s\n", messageID) + if mailUrgent { + fmt.Printf(" Priority: URGENT\n") + } + return nil + } + + // Direct mode + if err := store.CreateIssue(rootCtx, reply, actor); err != nil { + return fmt.Errorf("failed to send reply: %w", err) + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "id": reply.ID, + "to": recipient, + "from": sender, + "subject": subject, + "replies_to": messageID, + "priority": priority, + "timestamp": reply.CreatedAt, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("Reply sent: %s\n", reply.ID) + fmt.Printf(" To: %s\n", recipient) + fmt.Printf(" Re: %s\n", messageID) + if mailUrgent { + fmt.Printf(" Priority: URGENT\n") + } + + return nil +} diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 59d12761..8446d587 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -18,6 +18,7 @@ import ( "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/debug" + "github.com/steveyegge/beads/internal/hooks" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/memory" @@ -83,6 +84,9 @@ var ( // Auto-flush manager (replaces timer-based approach to fix bd-52) flushManager *FlushManager + // Hook runner for extensibility (bd-kwro.8) + hookRunner *hooks.Runner + // skipFinalFlush is set by sync command when sync.branch mode completes successfully. // This prevents PersistentPostRun from re-exporting and dirtying the working directory. skipFinalFlush = false @@ -599,6 +603,13 @@ var rootCmd = &cobra.Command{ flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration()) } + // Initialize hook runner (bd-kwro.8) + // dbPath is .beads/something.db, so workspace root is parent of .beads + if dbPath != "" { + beadsDir := filepath.Dir(dbPath) + hookRunner = hooks.NewRunner(filepath.Join(beadsDir, "hooks")) + } + // Warn if multiple databases detected in directory hierarchy warnMultipleDatabases(dbPath) diff --git a/cmd/bd/relate.go b/cmd/bd/relate.go new file mode 100644 index 00000000..eb71db9b --- /dev/null +++ b/cmd/bd/relate.go @@ -0,0 +1,295 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" +) + +var relateCmd = &cobra.Command{ + Use: "relate ", + Short: "Create a bidirectional relates_to link between issues", + Long: `Create a loose 'see also' relationship between two issues. + +The relates_to link is bidirectional - both issues will reference each other. +This enables knowledge graph connections without blocking or hierarchy. + +Examples: + bd relate bd-abc bd-xyz # Link two related issues + bd relate bd-123 bd-456 # Create see-also connection`, + Args: cobra.ExactArgs(2), + RunE: runRelate, +} + +var unrelateCmd = &cobra.Command{ + Use: "unrelate ", + Short: "Remove a relates_to link between issues", + Long: `Remove a relates_to relationship between two issues. + +Removes the link in both directions. + +Example: + bd unrelate bd-abc bd-xyz`, + Args: cobra.ExactArgs(2), + RunE: runUnrelate, +} + +func init() { + rootCmd.AddCommand(relateCmd) + rootCmd.AddCommand(unrelateCmd) +} + +func runRelate(cmd *cobra.Command, args []string) error { + CheckReadonly("relate") + + ctx := rootCtx + + // Resolve partial IDs + var id1, id2 string + if daemonClient != nil { + resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + if err := json.Unmarshal(resp1.Data, &id1); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[1]}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[1], err) + } + if err := json.Unmarshal(resp2.Data, &id2); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + id1, err = utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + id2, err = utils.ResolvePartialID(ctx, store, args[1]) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[1], err) + } + } + + if id1 == id2 { + return fmt.Errorf("cannot relate an issue to itself") + } + + // Get both issues + var issue1, issue2 *types.Issue + if daemonClient != nil { + resp1, err := daemonClient.Show(&rpc.ShowArgs{ID: id1}) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id1, err) + } + if err := json.Unmarshal(resp1.Data, &issue1); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + resp2, err := daemonClient.Show(&rpc.ShowArgs{ID: id2}) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id2, err) + } + if err := json.Unmarshal(resp2.Data, &issue2); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + issue1, err = store.GetIssue(ctx, id1) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id1, err) + } + issue2, err = store.GetIssue(ctx, id2) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id2, err) + } + } + + if issue1 == nil { + return fmt.Errorf("issue not found: %s", id1) + } + if issue2 == nil { + return fmt.Errorf("issue not found: %s", id2) + } + + // Add id2 to issue1's relates_to if not already present + if !contains(issue1.RelatesTo, id2) { + newRelatesTo1 := append(issue1.RelatesTo, id2) + if err := store.UpdateIssue(ctx, id1, map[string]interface{}{ + "relates_to": formatRelatesTo(newRelatesTo1), + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id1, err) + } + } + + // Add id1 to issue2's relates_to if not already present + if !contains(issue2.RelatesTo, id1) { + newRelatesTo2 := append(issue2.RelatesTo, id1) + if err := store.UpdateIssue(ctx, id2, map[string]interface{}{ + "relates_to": formatRelatesTo(newRelatesTo2), + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id2, err) + } + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "id1": id1, + "id2": id2, + "related": true, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Linked %s ↔ %s\n", green("✓"), id1, id2) + return nil +} + +func runUnrelate(cmd *cobra.Command, args []string) error { + CheckReadonly("unrelate") + + ctx := rootCtx + + // Resolve partial IDs + var id1, id2 string + if daemonClient != nil { + resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + if err := json.Unmarshal(resp1.Data, &id1); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[1]}) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[1], err) + } + if err := json.Unmarshal(resp2.Data, &id2); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + id1, err = utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[0], err) + } + id2, err = utils.ResolvePartialID(ctx, store, args[1]) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", args[1], err) + } + } + + // Get both issues + var issue1, issue2 *types.Issue + if daemonClient != nil { + resp1, err := daemonClient.Show(&rpc.ShowArgs{ID: id1}) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id1, err) + } + if err := json.Unmarshal(resp1.Data, &issue1); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + resp2, err := daemonClient.Show(&rpc.ShowArgs{ID: id2}) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id2, err) + } + if err := json.Unmarshal(resp2.Data, &issue2); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + issue1, err = store.GetIssue(ctx, id1) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id1, err) + } + issue2, err = store.GetIssue(ctx, id2) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", id2, err) + } + } + + if issue1 == nil { + return fmt.Errorf("issue not found: %s", id1) + } + if issue2 == nil { + return fmt.Errorf("issue not found: %s", id2) + } + + // Remove id2 from issue1's relates_to + newRelatesTo1 := remove(issue1.RelatesTo, id2) + if err := store.UpdateIssue(ctx, id1, map[string]interface{}{ + "relates_to": formatRelatesTo(newRelatesTo1), + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id1, err) + } + + // Remove id1 from issue2's relates_to + newRelatesTo2 := remove(issue2.RelatesTo, id1) + if err := store.UpdateIssue(ctx, id2, map[string]interface{}{ + "relates_to": formatRelatesTo(newRelatesTo2), + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id2, err) + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "id1": id1, + "id2": id2, + "unrelated": true, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Unlinked %s ↔ %s\n", green("✓"), id1, id2) + return nil +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func remove(slice []string, item string) []string { + result := make([]string, 0, len(slice)) + for _, s := range slice { + if s != item { + result = append(result, s) + } + } + return result +} + +func formatRelatesTo(ids []string) string { + if len(ids) == 0 { + return "" + } + data, _ := json.Marshal(ids) + return string(data) +} diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 587564b2..b98312dc 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -1,14 +1,17 @@ package main import ( + "context" "encoding/json" "fmt" "os" "os/exec" + "sort" "strings" "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/hooks" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" @@ -22,6 +25,7 @@ var showCmd = &cobra.Command{ Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") + showThread, _ := cmd.Flags().GetBool("thread") ctx := rootCtx // Check database freshness before reading (bd-2q6d, bd-c4rq) @@ -61,6 +65,12 @@ var showCmd = &cobra.Command{ } } + // Handle --thread flag: show full conversation thread + if showThread && len(resolvedIDs) > 0 { + showMessageThread(ctx, resolvedIDs[0], jsonOutput) + return + } + // If daemon is running, use RPC if daemonClient != nil { allDetails := []interface{}{} @@ -637,12 +647,17 @@ var updateCmd = &cobra.Command{ continue } - if jsonOutput { - var issue types.Issue - if err := json.Unmarshal(resp.Data, &issue); err == nil { + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err == nil { + // Run update hook (bd-kwro.8) + if hookRunner != nil { + hookRunner.Run(hooks.EventUpdate, &issue) + } + if jsonOutput { updatedIssues = append(updatedIssues, &issue) } - } else { + } + if !jsonOutput { green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Updated issue: %s\n", green("✓"), id) } @@ -716,8 +731,13 @@ var updateCmd = &cobra.Command{ } } + // Run update hook (bd-kwro.8) + issue, _ := store.GetIssue(ctx, id) + if issue != nil && hookRunner != nil { + hookRunner.Run(hooks.EventUpdate, issue) + } + if jsonOutput { - issue, _ := store.GetIssue(ctx, id) if issue != nil { updatedIssues = append(updatedIssues, issue) } @@ -990,12 +1010,17 @@ var closeCmd = &cobra.Command{ continue } - if jsonOutput { - var issue types.Issue - if err := json.Unmarshal(resp.Data, &issue); err == nil { + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err == nil { + // Run close hook (bd-kwro.8) + if hookRunner != nil { + hookRunner.Run(hooks.EventClose, &issue) + } + if jsonOutput { closedIssues = append(closedIssues, &issue) } - } else { + } + if !jsonOutput { green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) } @@ -1014,8 +1039,14 @@ var closeCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) continue } + + // Run close hook (bd-kwro.8) + issue, _ := store.GetIssue(ctx, id) + if issue != nil && hookRunner != nil { + hookRunner.Run(hooks.EventClose, issue) + } + if jsonOutput { - issue, _ := store.GetIssue(ctx, id) if issue != nil { closedIssues = append(closedIssues, issue) } @@ -1036,8 +1067,178 @@ var closeCmd = &cobra.Command{ }, } +// showMessageThread displays a full conversation thread for a message +func showMessageThread(ctx context.Context, messageID string, jsonOutput bool) { + // Get the starting message + var startMsg *types.Issue + var err error + + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err) + os.Exit(1) + } + if err := json.Unmarshal(resp.Data, &startMsg); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) + os.Exit(1) + } + } else { + startMsg, err = store.GetIssue(ctx, messageID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err) + os.Exit(1) + } + } + + if startMsg == nil { + fmt.Fprintf(os.Stderr, "Message %s not found\n", messageID) + os.Exit(1) + } + + // Find the root of the thread by following replies_to chain upward + rootMsg := startMsg + seen := make(map[string]bool) + seen[rootMsg.ID] = true + + for rootMsg.RepliesTo != "" { + if seen[rootMsg.RepliesTo] { + break // Avoid infinite loops + } + seen[rootMsg.RepliesTo] = true + + var parentMsg *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: rootMsg.RepliesTo}) + if err != nil { + break // Parent not found, use current as root + } + if err := json.Unmarshal(resp.Data, &parentMsg); err != nil { + break + } + } else { + parentMsg, _ = store.GetIssue(ctx, rootMsg.RepliesTo) + } + if parentMsg == nil { + break + } + rootMsg = parentMsg + } + + // Now collect all messages in the thread + // Start from root and find all replies + threadMessages := []*types.Issue{rootMsg} + threadIDs := map[string]bool{rootMsg.ID: true} + queue := []string{rootMsg.ID} + + // BFS to find all replies + for len(queue) > 0 { + currentID := queue[0] + queue = queue[1:] + + // Find all messages that reply to currentID + var replies []*types.Issue + if daemonClient != nil { + // In daemon mode, search for messages with replies_to = currentID + // Use list with a filter (simplified: we'll search all messages) + // This is inefficient but works for now + listArgs := &rpc.ListArgs{IssueType: "message"} + resp, err := daemonClient.List(listArgs) + if err == nil { + var allMessages []*types.Issue + if err := json.Unmarshal(resp.Data, &allMessages); err == nil { + for _, msg := range allMessages { + if msg.RepliesTo == currentID && !threadIDs[msg.ID] { + replies = append(replies, msg) + } + } + } + } + } else { + // Direct mode - search for replies + messageType := types.TypeMessage + filter := types.IssueFilter{IssueType: &messageType} + allMessages, _ := store.SearchIssues(ctx, "", filter) + for _, msg := range allMessages { + if msg.RepliesTo == currentID && !threadIDs[msg.ID] { + replies = append(replies, msg) + } + } + } + + for _, reply := range replies { + threadMessages = append(threadMessages, reply) + threadIDs[reply.ID] = true + queue = append(queue, reply.ID) + } + } + + // Sort by creation time + sort.Slice(threadMessages, func(i, j int) bool { + return threadMessages[i].CreatedAt.Before(threadMessages[j].CreatedAt) + }) + + if jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + _ = encoder.Encode(threadMessages) + return + } + + // Display the thread + cyan := color.New(color.FgCyan).SprintFunc() + dim := color.New(color.Faint).SprintFunc() + + fmt.Printf("\n%s Thread: %s\n", cyan("📬"), rootMsg.Title) + fmt.Println(strings.Repeat("─", 66)) + + for _, msg := range threadMessages { + // Show indent based on depth (count replies_to chain) + depth := 0 + parent := msg.RepliesTo + for parent != "" && depth < 5 { + depth++ + // Find parent to get its replies_to + for _, m := range threadMessages { + if m.ID == parent { + parent = m.RepliesTo + break + } + } + } + indent := strings.Repeat(" ", depth) + + // Format timestamp + timeStr := msg.CreatedAt.Format("2006-01-02 15:04") + + // Status indicator + statusIcon := "📧" + if msg.Status == types.StatusClosed { + statusIcon = "✓" + } + + fmt.Printf("%s%s %s %s\n", indent, statusIcon, cyan(msg.ID), dim(timeStr)) + fmt.Printf("%s From: %s To: %s\n", indent, msg.Sender, msg.Assignee) + if msg.RepliesTo != "" { + fmt.Printf("%s Re: %s\n", indent, msg.RepliesTo) + } + fmt.Printf("%s %s: %s\n", indent, dim("Subject"), msg.Title) + if msg.Description != "" { + // Indent the body + bodyLines := strings.Split(msg.Description, "\n") + for _, line := range bodyLines { + fmt.Printf("%s %s\n", indent, line) + } + } + fmt.Println() + } + + fmt.Printf("Total: %d messages in thread\n\n", len(threadMessages)) +} + func init() { showCmd.Flags().Bool("json", false, "Output JSON format") + showCmd.Flags().Bool("thread", false, "Show full conversation thread (for messages)") rootCmd.AddCommand(showCmd) updateCmd.Flags().StringP("status", "s", "", "New status") diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go new file mode 100644 index 00000000..b21c46a8 --- /dev/null +++ b/internal/hooks/hooks.go @@ -0,0 +1,161 @@ +// Package hooks provides a hook system for extensibility. +// Hooks are executable scripts in .beads/hooks/ that run after certain events. +package hooks + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +// Event types +const ( + EventCreate = "create" + EventUpdate = "update" + EventClose = "close" + EventMessage = "message" +) + +// Hook file names +const ( + HookOnCreate = "on_create" + HookOnUpdate = "on_update" + HookOnClose = "on_close" + HookOnMessage = "on_message" +) + +// Runner handles hook execution +type Runner struct { + hooksDir string + timeout time.Duration +} + +// NewRunner creates a new hook runner. +// hooksDir is typically .beads/hooks/ relative to workspace root. +func NewRunner(hooksDir string) *Runner { + return &Runner{ + hooksDir: hooksDir, + timeout: 10 * time.Second, + } +} + +// NewRunnerFromWorkspace creates a hook runner for a workspace. +func NewRunnerFromWorkspace(workspaceRoot string) *Runner { + return NewRunner(filepath.Join(workspaceRoot, ".beads", "hooks")) +} + +// Run executes a hook if it exists. +// Runs asynchronously - returns immediately, hook runs in background. +func (r *Runner) Run(event string, issue *types.Issue) { + hookName := eventToHook(event) + if hookName == "" { + return + } + + hookPath := filepath.Join(r.hooksDir, hookName) + + // Check if hook exists and is executable + info, err := os.Stat(hookPath) + if err != nil || info.IsDir() { + return // Hook doesn't exist, skip silently + } + + // Check if executable (Unix) + if info.Mode()&0111 == 0 { + return // Not executable, skip + } + + // Run asynchronously + go r.runHook(hookPath, event, issue) +} + +// RunSync executes a hook synchronously and returns any error. +// Useful for testing or when you need to wait for the hook. +func (r *Runner) RunSync(event string, issue *types.Issue) error { + hookName := eventToHook(event) + if hookName == "" { + return nil + } + + hookPath := filepath.Join(r.hooksDir, hookName) + + // Check if hook exists and is executable + info, err := os.Stat(hookPath) + if err != nil || info.IsDir() { + return nil // Hook doesn't exist, skip silently + } + + if info.Mode()&0111 == 0 { + return nil // Not executable, skip + } + + return r.runHook(hookPath, event, issue) +} + +func (r *Runner) runHook(hookPath, event string, issue *types.Issue) error { + ctx, cancel := context.WithTimeout(context.Background(), r.timeout) + defer cancel() + + // Prepare JSON data for stdin + issueJSON, err := json.Marshal(issue) + if err != nil { + return err + } + + // Create command: hook_script + // #nosec G204 -- hookPath is from controlled .beads/hooks directory + cmd := exec.CommandContext(ctx, hookPath, issue.ID, event) + cmd.Stdin = bytes.NewReader(issueJSON) + + // Capture output for debugging (but don't block on it) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Run the hook + err = cmd.Run() + if err != nil { + // Log error but don't fail - hooks shouldn't break beads + // In production, this could go to a log file + return err + } + + return nil +} + +// HookExists checks if a hook exists for an event +func (r *Runner) HookExists(event string) bool { + hookName := eventToHook(event) + if hookName == "" { + return false + } + + hookPath := filepath.Join(r.hooksDir, hookName) + info, err := os.Stat(hookPath) + if err != nil || info.IsDir() { + return false + } + + return info.Mode()&0111 != 0 +} + +func eventToHook(event string) string { + switch event { + case EventCreate: + return HookOnCreate + case EventUpdate: + return HookOnUpdate + case EventClose: + return HookOnClose + case EventMessage: + return HookOnMessage + default: + return "" + } +} diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index a5a8552b..55591ed2 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -72,6 +72,10 @@ type CreateArgs struct { EstimatedMinutes *int `json:"estimated_minutes,omitempty"` // Time estimate in minutes Labels []string `json:"labels,omitempty"` Dependencies []string `json:"dependencies,omitempty"` + // Messaging fields (bd-kwro) + Sender string `json:"sender,omitempty"` // Who sent this (for messages) + Ephemeral bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed + RepliesTo string `json:"replies_to,omitempty"` // Issue ID for conversation threading } // UpdateArgs represents arguments for the update operation @@ -91,6 +95,10 @@ type UpdateArgs struct { AddLabels []string `json:"add_labels,omitempty"` RemoveLabels []string `json:"remove_labels,omitempty"` SetLabels []string `json:"set_labels,omitempty"` + // Messaging fields (bd-kwro) + Sender *string `json:"sender,omitempty"` // Who sent this (for messages) + Ephemeral *bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed + RepliesTo *string `json:"replies_to,omitempty"` // Issue ID for conversation threading } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 6617764d..ec18abb0 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -76,6 +76,16 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.IssueType != nil { u["issue_type"] = *a.IssueType } + // Messaging fields (bd-kwro) + if a.Sender != nil { + u["sender"] = *a.Sender + } + if a.Ephemeral != nil { + u["ephemeral"] = *a.Ephemeral + } + if a.RepliesTo != nil { + u["replies_to"] = *a.RepliesTo + } return u } @@ -150,6 +160,10 @@ func (s *Server) handleCreate(req *Request) Response { ExternalRef: externalRef, EstimatedMinutes: createArgs.EstimatedMinutes, Status: types.StatusOpen, + // Messaging fields (bd-kwro) + Sender: createArgs.Sender, + Ephemeral: createArgs.Ephemeral, + RepliesTo: createArgs.RepliesTo, } // Check if any dependencies are discovered-from type