feat: add graph links and hooks system (bd-kwro.2-5, bd-kwro.8)

- bd mail reply: reply to messages with thread linking via replies_to
- bd show --thread: display full conversation threads
- bd relate/unrelate: bidirectional relates_to links for knowledge graph
- bd duplicate --of: mark issues as duplicates with auto-close
- bd supersede --with: mark issues as superseded with auto-close
- Hooks system: on_create, on_update, on_close, on_message in .beads/hooks/
- RPC protocol: added Sender, Ephemeral, RepliesTo fields to CreateArgs/UpdateArgs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-16 18:34:48 -08:00
parent 5e39a0a24f
commit 46bfb43b8d
9 changed files with 1132 additions and 20 deletions

View File

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