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

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

230
cmd/bd/duplicate.go Normal file
View File

@@ -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 <id> --of <canonical>",
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 <id> --with <new>",
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
}

View File

@@ -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 <id> -m <body>",
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
}

View File

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

295
cmd/bd/relate.go Normal file
View File

@@ -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 <id1> <id2>",
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 <id1> <id2>",
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)
}

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

161
internal/hooks/hooks.go Normal file
View File

@@ -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 <issue_id> <event_type>
// #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 ""
}
}

View File

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

View File

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