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

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