Merge branch 'main' into subtle-ux-improvements

Resolved modify/delete conflict: cmd/bd/mail.go was deleted on main
(mail functionality moved to Gas Town), accepting deletion.
This commit is contained in:
Steve Yegge
2025-12-20 23:26:35 -08:00
39 changed files with 1216 additions and 1652 deletions

View File

@@ -288,6 +288,27 @@ type VersionChange struct {
// versionChanges contains agent-actionable changes for recent versions
var versionChanges = []VersionChange{
{
Version: "0.32.1",
Date: "2025-12-21",
Changes: []string{
"NEW: MCP output control params (PR#667) - brief, brief_deps, fields, max_description_length",
"NEW: MCP filtering params - labels, labels_any, query, unassigned, sort_policy",
"NEW: BriefIssue, BriefDep, OperationResult models for 97% context reduction",
"FIX: Pin field not in allowed update fields (gt-zr0a) - bd update --pinned now works",
},
},
{
Version: "0.32.0",
Date: "2025-12-20",
Changes: []string{
"REMOVED: bd mail commands (send, inbox, read, ack, reply) - Mail is orchestration, not data plane",
"NOTE: Data model unchanged - type=message, Sender, Ephemeral, replies_to fields remain",
"NOTE: Orchestration tools should implement mail UI on top of beads data model",
"FIX: Symlink preservation in atomicWriteFile (PR#665) - bd setup no longer clobbers nix/home-manager configs",
"FIX: Broken link to LABELS.md in examples (GH#666)",
},
},
{
Version: "0.31.0",
Date: "2025-12-20",

View File

@@ -39,9 +39,9 @@ func parseTimeFlag(s string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unable to parse time %q (try formats: 2006-01-02, 2006-01-02T15:04:05, or RFC3339)", s)
}
// pinIndicator returns a pushpin emoji prefix for pinned issues (bd-18b)
// pinIndicator returns a pushpin emoji prefix for pinned issues (bd-18b, bd-7h5)
func pinIndicator(issue *types.Issue) string {
if issue.Status == types.StatusPinned {
if issue.Pinned {
return "📌 "
}
return ""

View File

@@ -1,728 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"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"
)
var mailCmd = &cobra.Command{
Use: "mail",
GroupID: "advanced",
Short: "Send and receive messages via beads",
Long: `Send and receive messages between agents using beads storage.
Messages are stored as issues with type=message, enabling git-native
inter-agent communication without external services.
Examples:
bd mail send worker-1 -s "Task complete" -m "Finished bd-xyz"
bd mail inbox
bd mail read bd-abc123
bd mail ack bd-abc123`,
}
var mailSendCmd = &cobra.Command{
Use: "send <recipient> -s <subject> -m <body>",
Short: "Send a message to another agent",
Long: `Send a message to another agent via beads.
Creates an issue with type=message, sender=your identity, assignee=recipient.
The --urgent flag sets priority=0. Use --priority for explicit priority control.
Priority levels: 0=critical/urgent, 1=high, 2=normal (default), 3=low, 4=backlog
Examples:
bd mail send worker-1 -s "Task complete" -m "Finished bd-xyz"
bd mail send worker-1 -s "Help needed" -m "Blocked on auth" --urgent
bd mail send worker-1 -s "Quick note" -m "FYI" --priority 3
bd mail send worker-1 -s "Task" -m "Do this" --type task --priority 1
bd mail send worker-1 -s "Re: Hello" -m "Hi back" --reply-to bd-abc123
bd mail send worker-1 -s "Quick note" -m "FYI" --identity refinery`,
Args: cobra.ExactArgs(1),
RunE: runMailSend,
}
var mailInboxCmd = &cobra.Command{
Use: "inbox",
Short: "List messages addressed to you",
Long: `List open messages where assignee matches your identity.
Messages are sorted by priority (urgent first), then by date (newest first).
Examples:
bd mail inbox
bd mail inbox --from worker-1
bd mail inbox --priority 0`,
RunE: runMailInbox,
}
var mailReadCmd = &cobra.Command{
Use: "read <id>",
Short: "Read a specific message",
Long: `Display the full content of a message.
Does NOT mark the message as read - use 'bd mail ack' for that.
Example:
bd mail read bd-abc123`,
Args: cobra.ExactArgs(1),
RunE: runMailRead,
}
var mailAckCmd = &cobra.Command{
Use: "ack <id> [id2...]",
Short: "Acknowledge (close) messages",
Long: `Mark messages as read by closing them.
Can acknowledge multiple messages at once.
Examples:
bd mail ack bd-abc123
bd mail ack bd-abc123 bd-def456 bd-ghi789`,
Args: cobra.MinimumNArgs(1),
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
mailBody string
mailUrgent bool
mailIdentity string
mailFrom string
mailPriorityFlag int
// New flags for GGT compatibility (gt-8j8e)
mailSendPriority int // Numeric priority for mail send (0-4)
mailMsgType string // Message type (task, scavenge, notification, reply)
mailThreadID string // Thread ID for conversation grouping
mailReplyTo string // Message ID being replied to
)
func init() {
rootCmd.AddCommand(mailCmd)
mailCmd.AddCommand(mailSendCmd)
mailCmd.AddCommand(mailInboxCmd)
mailCmd.AddCommand(mailReadCmd)
mailCmd.AddCommand(mailAckCmd)
mailCmd.AddCommand(mailReplyCmd)
// Send command flags
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
mailSendCmd.Flags().StringVarP(&mailBody, "body", "m", "", "Message body (required)")
mailSendCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)")
mailSendCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override sender identity")
// GGT compatibility flags (gt-8j8e)
mailSendCmd.Flags().IntVar(&mailSendPriority, "priority", -1, "Message priority (0-4, where 0=urgent)")
mailSendCmd.Flags().StringVar(&mailMsgType, "type", "", "Message type (task, scavenge, notification, reply)")
mailSendCmd.Flags().StringVar(&mailThreadID, "thread-id", "", "Thread ID for conversation grouping")
mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to")
_ = mailSendCmd.MarkFlagRequired("subject")
_ = mailSendCmd.MarkFlagRequired("body")
// Inbox command flags
mailInboxCmd.Flags().StringVar(&mailFrom, "from", "", "Filter by sender")
mailInboxCmd.Flags().IntVar(&mailPriorityFlag, "priority", -1, "Filter by priority (0-4)")
mailInboxCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override recipient identity")
// Read command flags
mailReadCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity for access check")
// 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 {
CheckReadonly("mail send")
recipient := args[0]
sender := config.GetIdentity(mailIdentity)
// Determine priority (gt-8j8e: --priority takes precedence over --urgent)
priority := 2 // default: normal
if cmd.Flags().Changed("priority") && mailSendPriority >= 0 && mailSendPriority <= 4 {
priority = mailSendPriority
} else if mailUrgent {
priority = 0
}
// Build labels for GGT metadata (gt-8j8e)
var labels []string
if mailMsgType != "" {
labels = append(labels, "msg-type:"+mailMsgType)
}
if mailThreadID != "" {
labels = append(labels, "thread:"+mailThreadID)
}
// If daemon is running, use RPC
if daemonClient != nil {
createArgs := &rpc.CreateArgs{
Title: mailSubject,
Description: mailBody,
IssueType: string(types.TypeMessage),
Priority: priority,
Assignee: recipient,
Sender: sender,
Ephemeral: true, // Messages can be bulk-deleted
Labels: labels,
RepliesTo: mailReplyTo, // Thread link (gt-8j8e)
}
resp, err := daemonClient.Create(createArgs)
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
// 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)
}
// Run message hook (bd-kwro.8)
if hookRunner != nil {
hookRunner.Run(hooks.EventMessage, &issue)
}
if jsonOutput {
result := map[string]interface{}{
"id": issue.ID,
"to": recipient,
"from": sender,
"subject": mailSubject,
"priority": priority,
"timestamp": issue.CreatedAt,
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
fmt.Printf("Message sent: %s\n", issue.ID)
fmt.Printf(" To: %s\n", recipient)
fmt.Printf(" Subject: %s\n", mailSubject)
if priority <= 1 {
fmt.Printf(" Priority: P%d\n", priority)
}
return nil
}
// Direct mode
now := time.Now()
issue := &types.Issue{
Title: mailSubject,
Description: mailBody,
Status: types.StatusOpen,
Priority: priority,
IssueType: types.TypeMessage,
Assignee: recipient,
Sender: sender,
Ephemeral: true, // Messages can be bulk-deleted
Labels: labels,
CreatedAt: now,
UpdatedAt: now,
}
if err := store.CreateIssue(rootCtx, issue, actor); err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
// Add reply-to dependency if specified (gt-8j8e)
if mailReplyTo != "" {
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: mailReplyTo,
Type: types.DepRepliesTo,
CreatedAt: now,
CreatedBy: actor,
}
if err := store.AddDependency(rootCtx, dep, actor); err != nil {
// Log but don't fail - the message was still sent
fmt.Fprintf(os.Stderr, "Warning: failed to create reply-to link: %v\n", err)
}
}
// Trigger auto-flush
if flushManager != nil {
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,
"to": recipient,
"from": sender,
"subject": mailSubject,
"priority": priority,
"timestamp": issue.CreatedAt,
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
fmt.Printf("Message sent: %s\n", issue.ID)
fmt.Printf(" To: %s\n", recipient)
fmt.Printf(" Subject: %s\n", mailSubject)
if priority <= 1 {
fmt.Printf(" Priority: P%d\n", priority)
}
return nil
}
func runMailInbox(cmd *cobra.Command, args []string) error {
identity := config.GetIdentity(mailIdentity)
// Query for open messages assigned to this identity
messageType := types.TypeMessage
openStatus := types.StatusOpen
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &identity,
}
var issues []*types.Issue
var err error
if daemonClient != nil {
// Daemon mode - use RPC list
resp, rpcErr := daemonClient.List(&rpc.ListArgs{
Status: string(openStatus),
IssueType: string(messageType),
Assignee: identity,
})
if rpcErr != nil {
return fmt.Errorf("failed to fetch inbox: %w", rpcErr)
}
if err := json.Unmarshal(resp.Data, &issues); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
} else {
// Direct mode
issues, err = store.SearchIssues(rootCtx, "", filter)
if err != nil {
return fmt.Errorf("failed to fetch inbox: %w", err)
}
}
// Filter by sender if specified
var filtered []*types.Issue
for _, issue := range issues {
if mailFrom != "" && issue.Sender != mailFrom {
continue
}
// Filter by priority if specified
if cmd.Flags().Changed("priority") && mailPriorityFlag >= 0 && issue.Priority != mailPriorityFlag {
continue
}
filtered = append(filtered, issue)
}
// Sort by priority (ascending), then by date (descending)
// Priority 0 is highest priority
for i := 0; i < len(filtered)-1; i++ {
for j := i + 1; j < len(filtered); j++ {
swap := false
if filtered[i].Priority > filtered[j].Priority {
swap = true
} else if filtered[i].Priority == filtered[j].Priority {
if filtered[i].CreatedAt.Before(filtered[j].CreatedAt) {
swap = true
}
}
if swap {
filtered[i], filtered[j] = filtered[j], filtered[i]
}
}
}
if jsonOutput {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(filtered)
}
if len(filtered) == 0 {
fmt.Printf("No messages for %s\n", identity)
return nil
}
fmt.Printf("Inbox for %s (%d messages):\n\n", identity, len(filtered))
for _, msg := range filtered {
// Format timestamp
age := time.Since(msg.CreatedAt)
var timeStr string
if age < time.Hour {
timeStr = fmt.Sprintf("%dm ago", int(age.Minutes()))
} else if age < 24*time.Hour {
timeStr = fmt.Sprintf("%dh ago", int(age.Hours()))
} else {
timeStr = fmt.Sprintf("%dd ago", int(age.Hours()/24))
}
// Priority indicator
priorityStr := ""
if msg.Priority == 0 {
priorityStr = " [URGENT]"
} else if msg.Priority == 1 {
priorityStr = " [HIGH]"
}
fmt.Printf(" %s: %s%s\n", msg.ID, msg.Title, priorityStr)
fmt.Printf(" From: %s (%s)\n", msg.Sender, timeStr)
// NOTE: Thread info now in dependencies (Decision 004)
fmt.Println()
}
return nil
}
func runMailRead(cmd *cobra.Command, args []string) error {
messageID := args[0]
var issue *types.Issue
if daemonClient != nil {
// Daemon mode - use RPC show
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID})
if err != nil {
return fmt.Errorf("failed to read message: %w", err)
}
if err := json.Unmarshal(resp.Data, &issue); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
} else {
// Direct mode
var err error
issue, err = store.GetIssue(rootCtx, messageID)
if err != nil {
return fmt.Errorf("failed to read message: %w", err)
}
}
if issue == nil {
return fmt.Errorf("message not found: %s", messageID)
}
if issue.IssueType != types.TypeMessage {
return fmt.Errorf("%s is not a message (type: %s)", messageID, issue.IssueType)
}
if jsonOutput {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(issue)
}
// Display message
fmt.Println(strings.Repeat("─", 66))
fmt.Printf("ID: %s\n", issue.ID)
fmt.Printf("From: %s\n", issue.Sender)
fmt.Printf("To: %s\n", issue.Assignee)
fmt.Printf("Subject: %s\n", issue.Title)
fmt.Printf("Time: %s\n", issue.CreatedAt.Format("2006-01-02 15:04:05"))
if issue.Priority <= 1 {
fmt.Printf("Priority: P%d\n", issue.Priority)
}
// NOTE: Thread info (RepliesTo) now in dependencies (Decision 004)
fmt.Printf("Status: %s\n", issue.Status)
fmt.Println(strings.Repeat("─", 66))
fmt.Println()
fmt.Println(issue.Description)
fmt.Println()
return nil
}
func runMailAck(cmd *cobra.Command, args []string) error {
CheckReadonly("mail ack")
var acked []string
var errors []string
for _, messageID := range args {
var issue *types.Issue
if daemonClient != nil {
// Daemon mode - use RPC
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID})
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
continue
}
if err := json.Unmarshal(resp.Data, &issue); err != nil {
errors = append(errors, fmt.Sprintf("%s: parse error: %v", messageID, err))
continue
}
} else {
// Direct mode
var err error
issue, err = store.GetIssue(rootCtx, messageID)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
continue
}
}
if issue == nil {
errors = append(errors, fmt.Sprintf("%s: not found", messageID))
continue
}
if issue.IssueType != types.TypeMessage {
errors = append(errors, fmt.Sprintf("%s: not a message (type: %s)", messageID, issue.IssueType))
continue
}
if issue.Status == types.StatusClosed {
errors = append(errors, fmt.Sprintf("%s: already acknowledged", messageID))
continue
}
// Close the message
if daemonClient != nil {
// Daemon mode - use RPC close
_, err := daemonClient.CloseIssue(&rpc.CloseArgs{ID: messageID})
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
continue
}
// Fire close hook for GGT notifications (daemon mode)
if hookRunner != nil {
hookRunner.Run(hooks.EventClose, issue)
}
} else {
// Direct mode - use CloseIssue for proper close handling
if err := store.CloseIssue(rootCtx, messageID, "acknowledged", actor); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
continue
}
// Fire close hook for GGT notifications (direct mode)
if hookRunner != nil {
hookRunner.Run(hooks.EventClose, issue)
}
}
acked = append(acked, messageID)
}
// Trigger auto-flush if any messages were acked (direct mode only)
if len(acked) > 0 && flushManager != nil {
flushManager.MarkDirty(false)
}
if jsonOutput {
result := map[string]interface{}{
"acknowledged": acked,
"errors": errors,
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
for _, id := range acked {
fmt.Printf("Acknowledged: %s\n", id)
}
for _, errMsg := range errors {
fmt.Fprintf(os.Stderr, "Error: %s\n", errMsg)
}
if len(errors) > 0 && len(acked) == 0 {
return fmt.Errorf("failed to acknowledge any messages")
}
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,
// NOTE: RepliesTo now handled via dependency API (Decision 004)
CreatedAt: now,
UpdatedAt: now,
}
_ = messageID // RepliesTo handled via CreateArgs.RepliesTo -> server creates dependency
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)
}
// Fire message hook for GGT notifications
if hookRunner != nil {
hookRunner.Run(hooks.EventMessage, reply)
}
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

@@ -1,392 +0,0 @@
package main
import (
"context"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestMailSendAndInbox(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Set up global state
oldStore := store
oldRootCtx := rootCtx
oldActor := actor
store = testStore
rootCtx = ctx
actor = "test-user"
defer func() {
store = oldStore
rootCtx = oldRootCtx
actor = oldActor
}()
// Create a message (simulating mail send)
now := time.Now()
msg := &types.Issue{
Title: "Test Subject",
Description: "Test message body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "worker-1",
Sender: "manager",
Ephemeral: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, msg, actor); err != nil {
t.Fatalf("Failed to create message: %v", err)
}
// Query inbox for worker-1
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "worker-1"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d", len(messages))
}
if messages[0].Title != "Test Subject" {
t.Errorf("Title = %q, want %q", messages[0].Title, "Test Subject")
}
if messages[0].Sender != "manager" {
t.Errorf("Sender = %q, want %q", messages[0].Sender, "manager")
}
if !messages[0].Ephemeral {
t.Error("Ephemeral should be true")
}
}
func TestMailInboxEmpty(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Query inbox for non-existent user
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "nobody"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(messages) != 0 {
t.Errorf("Expected 0 messages, got %d", len(messages))
}
}
func TestMailAck(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create a message
now := time.Now()
msg := &types.Issue{
Title: "Ack Test",
Description: "Test body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "recipient",
Sender: "sender",
Ephemeral: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message: %v", err)
}
// Acknowledge (close) the message
if err := testStore.CloseIssue(ctx, msg.ID, "acknowledged", "test"); err != nil {
t.Fatalf("Failed to close message: %v", err)
}
// Verify it's closed
updated, err := testStore.GetIssue(ctx, msg.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if updated.Status != types.StatusClosed {
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
}
// Verify it no longer appears in inbox
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "recipient"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(messages) != 0 {
t.Errorf("Expected 0 messages in inbox after ack, got %d", len(messages))
}
}
func TestMailReply(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create original message
now := time.Now()
original := &types.Issue{
Title: "Original Subject",
Description: "Original body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "worker",
Sender: "manager",
Ephemeral: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, original, "test"); err != nil {
t.Fatalf("Failed to create original message: %v", err)
}
// Create reply (thread link now done via dependencies per Decision 004)
reply := &types.Issue{
Title: "Re: Original Subject",
Description: "Reply body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "manager", // Reply goes to original sender
Sender: "worker",
Ephemeral: true,
CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute),
}
if err := testStore.CreateIssue(ctx, reply, "test"); err != nil {
t.Fatalf("Failed to create reply: %v", err)
}
// Add replies-to dependency (thread link per Decision 004)
dep := &types.Dependency{
IssueID: reply.ID,
DependsOnID: original.ID,
Type: types.DepRepliesTo,
}
if err := testStore.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add replies-to dependency: %v", err)
}
// Verify reply has correct thread link via dependencies
deps, err := testStore.GetDependenciesWithMetadata(ctx, reply.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
var foundReplyLink bool
for _, d := range deps {
if d.DependencyType == types.DepRepliesTo && d.ID == original.ID {
foundReplyLink = true
break
}
}
if !foundReplyLink {
t.Errorf("Reply missing replies-to link to original message")
}
}
func TestMailPriority(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create messages with different priorities
now := time.Now()
messages := []struct {
title string
priority int
}{
{"Normal message", 2},
{"Urgent message", 0},
{"High priority", 1},
}
for i, m := range messages {
msg := &types.Issue{
Title: m.title,
Description: "Body",
Status: types.StatusOpen,
Priority: m.priority,
IssueType: types.TypeMessage,
Assignee: "inbox",
Sender: "sender",
Ephemeral: true,
CreatedAt: now.Add(time.Duration(i) * time.Minute),
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
}
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message %d: %v", i, err)
}
}
// Query all messages
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "inbox"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
results, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(results) != 3 {
t.Fatalf("Expected 3 messages, got %d", len(results))
}
// Verify we can filter by priority
urgentPriority := 0
urgentFilter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
Priority: &urgentPriority,
}
urgent, err := testStore.SearchIssues(ctx, "", urgentFilter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(urgent) != 1 {
t.Errorf("Expected 1 urgent message, got %d", len(urgent))
}
}
func TestMailTypeValidation(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create a regular issue (not a message)
now := time.Now()
task := &types.Issue{
Title: "Regular Task",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, task, "test"); err != nil {
t.Fatalf("Failed to create task: %v", err)
}
// Query for messages should not return the task
messageType := types.TypeMessage
filter := types.IssueFilter{
IssueType: &messageType,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
for _, m := range messages {
if m.ID == task.ID {
t.Errorf("Task %s should not appear in message query", task.ID)
}
}
}
func TestMailSenderField(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create messages from different senders
now := time.Now()
senders := []string{"alice", "bob", "charlie"}
for i, sender := range senders {
msg := &types.Issue{
Title: "Message from " + sender,
Description: "Body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "inbox",
Sender: sender,
Ephemeral: true,
CreatedAt: now.Add(time.Duration(i) * time.Minute),
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
}
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message from %s: %v", sender, err)
}
}
// Query all messages and verify sender
messageType := types.TypeMessage
filter := types.IssueFilter{
IssueType: &messageType,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
senderSet := make(map[string]bool)
for _, m := range messages {
if m.Sender != "" {
senderSet[m.Sender] = true
}
}
for _, s := range senders {
if !senderSet[s] {
t.Errorf("Sender %q not found in messages", s)
}
}
}

View File

@@ -4,12 +4,20 @@ import (
"fmt"
"os"
"path/filepath"
"github.com/steveyegge/beads/internal/utils"
)
// atomicWriteFile writes data to a file atomically using a unique temporary file.
// This prevents race conditions when multiple processes write to the same file.
// If path is a symlink, writes to the resolved target (preserving the symlink).
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
targetPath, err := utils.ResolveForWrite(path)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
dir := filepath.Dir(targetPath)
// Create unique temp file in same directory
tmpFile, err := os.CreateTemp(dir, ".*.tmp")
@@ -38,7 +46,7 @@ func atomicWriteFile(path string, data []byte) error {
}
// Atomic rename
if err := os.Rename(tmpPath, path); err != nil {
if err := os.Rename(tmpPath, targetPath); err != nil {
_ = os.Remove(tmpPath) // Best effort cleanup
return fmt.Errorf("rename temp file: %w", err)
}

View File

@@ -67,6 +67,45 @@ func TestAtomicWriteFile(t *testing.T) {
}
}
func TestAtomicWriteFile_PreservesSymlink(t *testing.T) {
tmpDir := t.TempDir()
// Create target file
target := filepath.Join(tmpDir, "target.txt")
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
t.Fatal(err)
}
// Create symlink
link := filepath.Join(tmpDir, "link.txt")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
// Write via symlink
if err := atomicWriteFile(link, []byte("updated")); err != nil {
t.Fatalf("atomicWriteFile failed: %v", err)
}
// Verify symlink still exists
info, err := os.Lstat(link)
if err != nil {
t.Fatalf("failed to lstat link: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Error("symlink was replaced with regular file")
}
// Verify target was updated
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("failed to read target: %v", err)
}
if string(data) != "updated" {
t.Errorf("target content = %q, want %q", string(data), "updated")
}
}
func TestDirExists(t *testing.T) {
tmpDir := t.TempDir()

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
# bd-hooks-version: 0.32.1
#
# bd (beads) post-checkout hook - thin shim
#

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
# bd-hooks-version: 0.32.1
#
# bd (beads) post-merge hook - thin shim
#

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
# bd-hooks-version: 0.32.1
#
# bd (beads) pre-commit hook - thin shim
#

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
# bd-hooks-version: 0.32.1
#
# bd (beads) pre-push hook - thin shim
#

View File

@@ -14,7 +14,7 @@ import (
var (
// Version is the current version of bd (overridden by ldflags at build time)
Version = "0.31.0"
Version = "0.32.1"
// Build can be set via ldflags at compile time
Build = "dev"
// Commit and branch the git revision the binary was built from (optional ldflag)

View File

@@ -149,67 +149,9 @@ func maybeShowUpgradeNotification() {
fmt.Println("💡 Run 'bd upgrade review' to see what changed")
fmt.Println("💊 Run 'bd doctor' to verify upgrade completed cleanly")
// Check if BD_GUIDE.md exists and needs updating
checkAndSuggestBDGuideUpdate()
fmt.Println()
}
// checkAndSuggestBDGuideUpdate checks if .beads/BD_GUIDE.md exists and suggests regeneration if outdated.
// bd-woro: Auto-update BD_GUIDE.md on version changes
func checkAndSuggestBDGuideUpdate() {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return
}
guidePath := beadsDir + "/BD_GUIDE.md"
// Check if BD_GUIDE.md exists
if _, err := os.Stat(guidePath); os.IsNotExist(err) {
// File doesn't exist - no suggestion needed
return
}
// Read first few lines to check version stamp
// #nosec G304 - guidePath is constructed from beadsDir + constant string
content, err := os.ReadFile(guidePath)
if err != nil {
return // Silent failure
}
// Look for version in the first 200 bytes (should be in the header)
header := string(content)
if len(header) > 200 {
header = header[:200]
}
// Check if the file has the old version stamp
oldVersionStamp := fmt.Sprintf("bd v%s", previousVersion)
currentVersionStamp := fmt.Sprintf("bd v%s", Version)
if containsSubstring(header, oldVersionStamp) && !containsSubstring(header, currentVersionStamp) {
// BD_GUIDE.md is outdated
fmt.Printf("📄 BD_GUIDE.md is outdated (v%s → v%s)\n", previousVersion, Version)
fmt.Printf("💡 Run 'bd onboard --output .beads/BD_GUIDE.md' to regenerate\n")
}
}
// containsSubstring checks if haystack contains needle (case-sensitive)
func containsSubstring(haystack, needle string) bool {
return len(haystack) >= len(needle) && findSubstring(haystack, needle) >= 0
}
// findSubstring returns the index of needle in haystack, or -1 if not found
func findSubstring(haystack, needle string) int {
for i := 0; i <= len(haystack)-len(needle); i++ {
if haystack[i:i+len(needle)] == needle {
return i
}
}
return -1
}
// findActualJSONLFile scans .beads/ for the actual JSONL file in use.
// Prefers issues.jsonl over beads.jsonl (canonical name), skips backups and merge artifacts.
// Returns empty string if no JSONL file is found.