feat: add session_id field to issue close/update mutations (bd-tksk)

Adds closed_by_session tracking for entity CV building per Gas Town
decision 009-session-events-architecture.md.

Changes:
- Add ClosedBySession field to Issue struct
- Add closed_by_session column to issues table (migration 034)
- Add --session flag to bd close command
- Support CLAUDE_SESSION_ID env var as fallback
- Add --session flag to bd update for status=closed
- Display closed_by_session in bd show output
- Update Storage interface to include session parameter in CloseIssue

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
beads/crew/dave
2025-12-31 13:13:49 -08:00
committed by Steve Yegge
parent 7c9b975436
commit b362b36824
42 changed files with 165 additions and 82 deletions

View File

@@ -46,6 +46,12 @@ create, update, show, or close operation).`,
noAuto, _ := cmd.Flags().GetBool("no-auto")
suggestNext, _ := cmd.Flags().GetBool("suggest-next")
// Get session ID from flag or environment variable
session, _ := cmd.Flags().GetString("session")
if session == "" {
session = os.Getenv("CLAUDE_SESSION_ID")
}
ctx := rootCtx
// --continue only works with a single issue
@@ -101,6 +107,7 @@ create, update, show, or close operation).`,
closeArgs := &rpc.CloseArgs{
ID: id,
Reason: reason,
Session: session,
SuggestNext: suggestNext,
}
resp, err := daemonClient.CloseIssue(closeArgs)
@@ -175,7 +182,7 @@ create, update, show, or close operation).`,
continue
}
if err := store.CloseIssue(ctx, id, reason, actor); err != nil {
if err := store.CloseIssue(ctx, id, reason, actor, session); err != nil {
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
continue
}
@@ -253,5 +260,6 @@ func init() {
closeCmd.Flags().Bool("continue", false, "Auto-advance to next step in molecule")
closeCmd.Flags().Bool("no-auto", false, "With --continue, show next step but don't claim it")
closeCmd.Flags().Bool("suggest-next", false, "Show newly unblocked issues after closing")
closeCmd.Flags().String("session", "", "Claude Code session ID (or set CLAUDE_SESSION_ID env var)")
rootCmd.AddCommand(closeCmd)
}

View File

@@ -118,7 +118,7 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
// NOW THE CRITICAL TEST: Agent A closes the issue and pushes
t.Run("DaemonAutoImportsAfterGitPull", func(t *testing.T) {
// Agent A closes the issue
if err := clone1Store.CloseIssue(ctx, issueID, "Completed", "agent-a"); err != nil {
if err := clone1Store.CloseIssue(ctx, issueID, "Completed", "agent-a", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err)
}
@@ -331,7 +331,7 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
// THE CORRUPTION SCENARIO:
// 1. Agent A closes the issue and pushes
clone1Store.CloseIssue(ctx, issueID, "Done", "agent-a")
clone1Store.CloseIssue(ctx, issueID, "Done", "agent-a", "")
exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath)
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
runGitCmd(t, clone1Dir, "commit", "-m", "Close issue")

View File

@@ -734,7 +734,7 @@ func TestSyncBranchIntegration_EndToEnd(t *testing.T) {
}
// Agent B closes the issue
store2.CloseIssue(ctx, issueID, "Done by Agent B", "agent-b")
store2.CloseIssue(ctx, issueID, "Done by Agent B", "agent-b", "")
exportToJSONLWithStore(ctx, store2, clone2JSONLPath)
// Agent B commits to sync branch

View File

@@ -283,7 +283,7 @@ func performMerge(targetID string, sourceIDs []string) map[string]interface{} {
for _, sourceID := range sourceIDs {
// Close the duplicate issue
reason := fmt.Sprintf("Duplicate of %s", targetID)
if err := store.CloseIssue(ctx, sourceID, reason, actor); err != nil {
if err := store.CloseIssue(ctx, sourceID, reason, actor, ""); err != nil {
errors = append(errors, fmt.Sprintf("failed to close %s: %v", sourceID, err))
continue
}

View File

@@ -166,7 +166,7 @@ var closeEligibleEpicsCmd = &cobra.Command{
}
} else {
ctx := rootCtx
err := store.CloseIssue(ctx, epicStatus.Epic.ID, "All children completed", "system")
err := store.CloseIssue(ctx, epicStatus.Epic.ID, "All children completed", "system", "")
if err != nil {
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", epicStatus.Epic.ID, err)
continue

View File

@@ -385,7 +385,7 @@ func TestCloseReasonRoundTrip(t *testing.T) {
// Close the issue with a reason
closeReason := "Completed: all tests passing"
if err := store.CloseIssue(ctx, issue.ID, closeReason, "test-actor"); err != nil {
if err := store.CloseIssue(ctx, issue.ID, closeReason, "test-actor", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err)
}

View File

@@ -425,7 +425,7 @@ var gateCloseCmd = &cobra.Command{
os.Exit(1)
}
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil {
if err := store.CloseIssue(ctx, gateID, reason, actor, ""); err != nil {
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
os.Exit(1)
}
@@ -543,7 +543,7 @@ Example:
reason = fmt.Sprintf("Human approval granted: %s (%s)", gate.AwaitID, comment)
}
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil {
if err := store.CloseIssue(ctx, gateID, reason, actor, ""); err != nil {
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
os.Exit(1)
}
@@ -807,7 +807,7 @@ This command is idempotent and safe to run repeatedly.`,
continue
}
} else if store != nil {
if err := store.CloseIssue(ctx, gate.ID, reason, actor); err != nil {
if err := store.CloseIssue(ctx, gate.ID, reason, actor, ""); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close gate %s: %v\n", gate.ID, err)
continue
}

View File

@@ -71,7 +71,7 @@ func TestGitPullSyncIntegration(t *testing.T) {
issueID := issue.ID
// Close the issue
if err := clone1Store.CloseIssue(ctx, issueID, "Test completed", "test-user"); err != nil {
if err := clone1Store.CloseIssue(ctx, issueID, "Test completed", "test-user", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err)
}

View File

@@ -263,7 +263,7 @@ func TestListQueryCapabilitiesSuite(t *testing.T) {
}
// Close issue3 to set closed_at timestamp
if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing"); err != nil {
if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing", ""); err != nil {
t.Fatalf("Failed to close issue3: %v", err)
}

View File

@@ -133,7 +133,7 @@ Examples:
// Step 7: Close the source issue (unless --keep-open)
if !keepOpen {
closeReason := fmt.Sprintf("Refiled to %s", newIssue.ID)
if err := result.Store.CloseIssue(ctx, resolvedSourceID, closeReason, actor); err != nil {
if err := result.Store.CloseIssue(ctx, resolvedSourceID, closeReason, actor, ""); err != nil {
WarnError("failed to close source issue: %v", err)
}
// Schedule auto-flush if source was local store

View File

@@ -30,7 +30,7 @@ func (h *reopenTestHelper) createIssue(title string, issueType types.IssueType,
}
func (h *reopenTestHelper) closeIssue(issueID, reason string) {
if err := h.s.CloseIssue(h.ctx, issueID, "test-user", reason); err != nil {
if err := h.s.CloseIssue(h.ctx, issueID, "test-user", reason, ""); err != nil {
h.t.Fatalf("Failed to close issue: %v", err)
}
}

View File

@@ -204,7 +204,7 @@ func TestSearchWithDateAndPriorityFilters(t *testing.T) {
}
// Close issue3 to set closed_at timestamp
if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing"); err != nil {
if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing", ""); err != nil {
t.Fatalf("Failed to close issue3: %v", err)
}

View File

@@ -209,6 +209,9 @@ var showCmd = &cobra.Command{
if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason)
}
if issue.ClosedBySession != "" {
fmt.Printf("Closed by session: %s\n", issue.ClosedBySession)
}
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" {
@@ -426,6 +429,9 @@ var showCmd = &cobra.Command{
if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason)
}
if issue.ClosedBySession != "" {
fmt.Printf("Closed by session: %s\n", issue.ClosedBySession)
}
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" {

View File

@@ -40,6 +40,17 @@ create, update, show, or close operation).`,
if cmd.Flags().Changed("status") {
status, _ := cmd.Flags().GetString("status")
updates["status"] = status
// If status is being set to closed, include session if provided
if status == "closed" {
session, _ := cmd.Flags().GetString("session")
if session == "" {
session = os.Getenv("CLAUDE_SESSION_ID")
}
if session != "" {
updates["closed_by_session"] = session
}
}
}
if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority")
@@ -412,5 +423,6 @@ func init() {
updateCmd.Flags().StringSlice("set-labels", nil, "Set labels, replacing all existing (repeatable)")
updateCmd.Flags().String("parent", "", "New parent issue ID (reparents the issue, use empty string to remove parent)")
updateCmd.Flags().Bool("claim", false, "Atomically claim the issue (sets assignee to you, status to in_progress; fails if already claimed)")
updateCmd.Flags().String("session", "", "Claude Code session ID for status=closed (or set CLAUDE_SESSION_ID env var)")
rootCmd.AddCommand(updateCmd)
}