diff --git a/.claude/settings.json b/.claude/settings.json index 1dbac45a..e06fa08e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,7 +9,7 @@ "hooks": [ { "type": "command", - "command": "gt prime" + "command": "bash ~/.claude/hooks/session-start.sh" } ] } diff --git a/cmd/bd/close.go b/cmd/bd/close.go index 015079fe..a07dfbcc 100644 --- a/cmd/bd/close.go +++ b/cmd/bd/close.go @@ -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) } diff --git a/cmd/bd/daemon_autoimport_test.go b/cmd/bd/daemon_autoimport_test.go index e959ba51..101e3a17 100644 --- a/cmd/bd/daemon_autoimport_test.go +++ b/cmd/bd/daemon_autoimport_test.go @@ -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") diff --git a/cmd/bd/daemon_sync_branch_test.go b/cmd/bd/daemon_sync_branch_test.go index 186c8533..aef826c4 100644 --- a/cmd/bd/daemon_sync_branch_test.go +++ b/cmd/bd/daemon_sync_branch_test.go @@ -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 diff --git a/cmd/bd/duplicates.go b/cmd/bd/duplicates.go index f16475a7..f8a217f2 100644 --- a/cmd/bd/duplicates.go +++ b/cmd/bd/duplicates.go @@ -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 } diff --git a/cmd/bd/epic.go b/cmd/bd/epic.go index 6d074698..8d1d67b9 100644 --- a/cmd/bd/epic.go +++ b/cmd/bd/epic.go @@ -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 diff --git a/cmd/bd/export_import_test.go b/cmd/bd/export_import_test.go index 6b54c538..7f78a4b6 100644 --- a/cmd/bd/export_import_test.go +++ b/cmd/bd/export_import_test.go @@ -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) } diff --git a/cmd/bd/gate.go b/cmd/bd/gate.go index 0141a319..df143a76 100644 --- a/cmd/bd/gate.go +++ b/cmd/bd/gate.go @@ -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 } diff --git a/cmd/bd/git_sync_test.go b/cmd/bd/git_sync_test.go index d68f0803..574ec001 100644 --- a/cmd/bd/git_sync_test.go +++ b/cmd/bd/git_sync_test.go @@ -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) } diff --git a/cmd/bd/list_test.go b/cmd/bd/list_test.go index 9407a5df..8d015414 100644 --- a/cmd/bd/list_test.go +++ b/cmd/bd/list_test.go @@ -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) } diff --git a/cmd/bd/refile.go b/cmd/bd/refile.go index 28db81cd..9df057a8 100644 --- a/cmd/bd/refile.go +++ b/cmd/bd/refile.go @@ -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 diff --git a/cmd/bd/reopen_test.go b/cmd/bd/reopen_test.go index c6210cbb..1662e145 100644 --- a/cmd/bd/reopen_test.go +++ b/cmd/bd/reopen_test.go @@ -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) } } diff --git a/cmd/bd/search_test.go b/cmd/bd/search_test.go index 338c73ad..3ca14673 100644 --- a/cmd/bd/search_test.go +++ b/cmd/bd/search_test.go @@ -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) } diff --git a/cmd/bd/show.go b/cmd/bd/show.go index bb6e6f1f..7d25e295 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -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 != "" { diff --git a/cmd/bd/update.go b/cmd/bd/update.go index 45b04e19..58bfd707 100644 --- a/cmd/bd/update.go +++ b/cmd/bd/update.go @@ -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) } diff --git a/examples/bd-example-extension-go/main.go b/examples/bd-example-extension-go/main.go index 54fe37ab..cb8219cb 100644 --- a/examples/bd-example-extension-go/main.go +++ b/examples/bd-example-extension-go/main.go @@ -65,7 +65,7 @@ func main() { // Complete db.Exec(`UPDATE example_executions SET status='completed', completed_at=? WHERE id=?`, time.Now(), execID) - store.CloseIssue(ctx, issue.ID, "Done", "demo-agent") + store.CloseIssue(ctx, issue.ID, "Done", "demo-agent", "") // Show status fmt.Println("\nStatus:") diff --git a/examples/library-usage/main.go b/examples/library-usage/main.go index 75a635d3..7b490388 100644 --- a/examples/library-usage/main.go +++ b/examples/library-usage/main.go @@ -120,7 +120,7 @@ func main() { // Example 8: Close the issue fmt.Println("\n=== Closing Issue ===") - if err := store.CloseIssue(ctx, newIssue.ID, "Completed demo", "library-example"); err != nil { + if err := store.CloseIssue(ctx, newIssue.ID, "Completed demo", "library-example", ""); err != nil { log.Fatalf("Failed to close issue: %v", err) } fmt.Printf("Closed issue %s\n", newIssue.ID) diff --git a/examples/library-usage/main_test.go b/examples/library-usage/main_test.go index dc8a90b3..8fb9532f 100644 --- a/examples/library-usage/main_test.go +++ b/examples/library-usage/main_test.go @@ -93,7 +93,7 @@ func TestExampleCompiles(t *testing.T) { } // Close issue (from example code) - if err := store.CloseIssue(ctx, newIssue.ID, "Test complete", "test"); err != nil { + if err := store.CloseIssue(ctx, newIssue.ID, "Test complete", "test", ""); err != nil { t.Fatalf("Failed to close issue: %v", err) } diff --git a/internal/beads/beads_integration_test.go b/internal/beads/beads_integration_test.go index e5adc29f..e701f3f4 100644 --- a/internal/beads/beads_integration_test.go +++ b/internal/beads/beads_integration_test.go @@ -67,7 +67,7 @@ func (h *integrationTestHelper) updateIssue(id string, updates map[string]interf } func (h *integrationTestHelper) closeIssue(id string, reason string) { - if err := h.store.CloseIssue(h.ctx, id, reason, "test-actor"); err != nil { + if err := h.store.CloseIssue(h.ctx, id, reason, "test-actor", ""); err != nil { h.t.Fatalf("CloseIssue failed: %v", err) } } diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 53719692..18151e4b 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -160,6 +160,7 @@ type UpdateArgs struct { type CloseArgs struct { ID string `json:"id"` Reason string `json:"reason,omitempty"` + Session string `json:"session,omitempty"` // Claude Code session ID that closed this issue SuggestNext bool `json:"suggest_next,omitempty"` // Return newly unblocked issues (GH#679) } diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 78f8325b..711aab49 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -737,7 +737,7 @@ func (s *Server) handleClose(req *Request) Response { oldStatus = string(issue.Status) } - if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil { + if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req), closeArgs.Session); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to close issue: %v", err), @@ -2069,7 +2069,7 @@ func (s *Server) handleGateClose(req *Request) Response { oldStatus := string(gate.Status) - if err := store.CloseIssue(ctx, gateID, reason, s.reqActor(req)); err != nil { + if err := store.CloseIssue(ctx, gateID, reason, s.reqActor(req), ""); err != nil { return Response{ Success: false, Error: fmt.Sprintf("failed to close gate: %v", err), diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 1ebe8c89..1afc45b7 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -443,6 +443,14 @@ func (m *MemoryStorage) UpdateIssue(ctx context.Context, id string, updates map[ } issue.ExternalRef = nil } + case "close_reason": + if v, ok := value.(string); ok { + issue.CloseReason = v + } + case "closed_by_session": + if v, ok := value.(string); ok { + issue.ClosedBySession = v + } } } @@ -467,11 +475,17 @@ func (m *MemoryStorage) UpdateIssue(ctx context.Context, id string, updates map[ return nil } -// CloseIssue closes an issue with a reason -func (m *MemoryStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error { - return m.UpdateIssue(ctx, id, map[string]interface{}{ - "status": string(types.StatusClosed), - }, actor) +// CloseIssue closes an issue with a reason. +// The session parameter tracks which Claude Code session closed the issue (can be empty). +func (m *MemoryStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error { + updates := map[string]interface{}{ + "status": string(types.StatusClosed), + "close_reason": reason, + } + if session != "" { + updates["closed_by_session"] = session + } + return m.UpdateIssue(ctx, id, updates, actor) } // CreateTombstone converts an existing issue to a tombstone record. diff --git a/internal/storage/memory/memory_more_coverage_test.go b/internal/storage/memory/memory_more_coverage_test.go index 82651647..844fee38 100644 --- a/internal/storage/memory/memory_more_coverage_test.go +++ b/internal/storage/memory/memory_more_coverage_test.go @@ -515,7 +515,7 @@ func TestMemoryStorage_GetStaleIssues_FilteringAndLimit(t *testing.T) { t.Fatalf("CreateIssue %s: %v", iss.ID, err) } } - if err := store.CloseIssue(ctx, closed.ID, "done", "actor"); err != nil { + if err := store.CloseIssue(ctx, closed.ID, "done", "actor", ""); err != nil { t.Fatalf("CloseIssue: %v", err) } @@ -555,10 +555,10 @@ func TestMemoryStorage_Statistics_EpicsEligibleForClosure_Counting(t *testing.T) t.Fatalf("CreateIssue %s: %v", iss.ID, err) } } - if err := store.CloseIssue(ctx, c1.ID, "done", "actor"); err != nil { + if err := store.CloseIssue(ctx, c1.ID, "done", "actor", ""); err != nil { t.Fatalf("CloseIssue c1: %v", err) } - if err := store.CloseIssue(ctx, c2.ID, "done", "actor"); err != nil { + if err := store.CloseIssue(ctx, c2.ID, "done", "actor", ""); err != nil { t.Fatalf("CloseIssue c2: %v", err) } // Parent-child deps: child -> epic. @@ -851,7 +851,7 @@ func TestMemoryStorage_UpdateIssue_CoversMoreFields(t *testing.T) { } // Status closed when already closed should not clear ClosedAt. - if err := store.CloseIssue(ctx, iss.ID, "done", "actor"); err != nil { + if err := store.CloseIssue(ctx, iss.ID, "done", "actor", ""); err != nil { t.Fatalf("CloseIssue: %v", err) } closedOnce, _ := store.GetIssue(ctx, iss.ID) @@ -881,7 +881,7 @@ func TestMemoryStorage_CountEpicsEligibleForClosure_CoversBranches(t *testing.T) t.Fatalf("CreateIssue %s: %v", iss.ID, err) } } - if err := store.CloseIssue(ctx, epClosed.ID, "done", "actor"); err != nil { + if err := store.CloseIssue(ctx, epClosed.ID, "done", "actor", ""); err != nil { t.Fatalf("CloseIssue: %v", err) } // Child -> ep1 (eligible once child is closed). @@ -898,7 +898,7 @@ func TestMemoryStorage_CountEpicsEligibleForClosure_CoversBranches(t *testing.T) store.mu.Unlock() // Close child to make ep1 eligible. - if err := store.CloseIssue(ctx, c.ID, "done", "actor"); err != nil { + if err := store.CloseIssue(ctx, c.ID, "done", "actor", ""); err != nil { t.Fatalf("CloseIssue child: %v", err) } diff --git a/internal/storage/memory/memory_test.go b/internal/storage/memory/memory_test.go index e6da2ee5..a4979500 100644 --- a/internal/storage/memory/memory_test.go +++ b/internal/storage/memory/memory_test.go @@ -320,7 +320,7 @@ func TestCloseIssue(t *testing.T) { } // Close it - if err := store.CloseIssue(ctx, issue.ID, "Completed", "test-user"); err != nil { + if err := store.CloseIssue(ctx, issue.ID, "Completed", "test-user", ""); err != nil { t.Fatalf("CloseIssue failed: %v", err) } @@ -733,7 +733,7 @@ func TestStatistics(t *testing.T) { } // Close the one marked as closed if issue.Status == types.StatusClosed { - if err := store.CloseIssue(ctx, issue.ID, "Done", "test-user"); err != nil { + if err := store.CloseIssue(ctx, issue.ID, "Done", "test-user", ""); err != nil { t.Fatalf("CloseIssue failed: %v", err) } } @@ -786,7 +786,7 @@ func TestStatistics_BlockedAndReadyCounts(t *testing.T) { } // Close the closedBlocker properly - if err := store.CloseIssue(ctx, closedBlocker.ID, "Done", "test"); err != nil { + if err := store.CloseIssue(ctx, closedBlocker.ID, "Done", "test", ""); err != nil { t.Fatalf("CloseIssue failed: %v", err) } @@ -878,7 +878,7 @@ func TestStatistics_EpicsEligibleForClosure(t *testing.T) { // Close the children properly for _, child := range []*types.Issue{child1, child2} { - if err := store.CloseIssue(ctx, child.ID, "Done", "test"); err != nil { + if err := store.CloseIssue(ctx, child.ID, "Done", "test", ""); err != nil { t.Fatalf("CloseIssue failed: %v", err) } } @@ -926,7 +926,7 @@ func TestStatistics_TombstonesExcludedFromTotal(t *testing.T) { } // Close the closed issue properly - if err := store.CloseIssue(ctx, issues[1].ID, "Done", "test"); err != nil { + if err := store.CloseIssue(ctx, issues[1].ID, "Done", "test", ""); err != nil { t.Fatalf("CloseIssue failed: %v", err) } diff --git a/internal/storage/sqlite/blocked_cache_test.go b/internal/storage/sqlite/blocked_cache_test.go index 4d05854c..e95a930c 100644 --- a/internal/storage/sqlite/blocked_cache_test.go +++ b/internal/storage/sqlite/blocked_cache_test.go @@ -126,7 +126,7 @@ func TestCacheInvalidationOnStatusChange(t *testing.T) { } // Close the blocker - if err := store.CloseIssue(ctx, blocker.ID, "Done", "test-user"); err != nil { + if err := store.CloseIssue(ctx, blocker.ID, "Done", "test-user", ""); err != nil { t.Fatalf("CloseIssue failed: %v", err) } @@ -186,7 +186,7 @@ func TestCacheConsistencyAcrossOperations(t *testing.T) { } // Operation 3: Close blocker1 - store.CloseIssue(ctx, blocker1.ID, "Done", "test-user") + store.CloseIssue(ctx, blocker1.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if cached[blocked1.ID] || !cached[blocked2.ID] { @@ -322,7 +322,7 @@ func TestDeepHierarchyCacheCorrectness(t *testing.T) { } // Close the blocker and verify all become unblocked - store.CloseIssue(ctx, blocker.ID, "Done", "test-user") + store.CloseIssue(ctx, blocker.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if len(cached) != 0 { @@ -359,7 +359,7 @@ func TestMultipleBlockersInCache(t *testing.T) { } // Close one blocker - should still be blocked - store.CloseIssue(ctx, blocker1.ID, "Done", "test-user") + store.CloseIssue(ctx, blocker1.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if !cached[blocked.ID] { @@ -367,7 +367,7 @@ func TestMultipleBlockersInCache(t *testing.T) { } // Close the second blocker - should be unblocked - store.CloseIssue(ctx, blocker2.ID, "Done", "test-user") + store.CloseIssue(ctx, blocker2.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if cached[blocked.ID] { @@ -400,7 +400,7 @@ func TestConditionalBlocksCache(t *testing.T) { } // Close A with SUCCESS (no failure keywords) - B should STILL be blocked - store.CloseIssue(ctx, issueA.ID, "Completed successfully", "test-user") + store.CloseIssue(ctx, issueA.ID, "Completed successfully", "test-user", "") cached = getCachedBlockedIssues(t, store) if !cached[issueB.ID] { @@ -411,7 +411,7 @@ func TestConditionalBlocksCache(t *testing.T) { store.UpdateIssue(ctx, issueA.ID, map[string]interface{}{"status": types.StatusOpen}, "test-user") // Close A with FAILURE - B should now be UNBLOCKED - store.CloseIssue(ctx, issueA.ID, "Task failed due to timeout", "test-user") + store.CloseIssue(ctx, issueA.ID, "Task failed due to timeout", "test-user", "") cached = getCachedBlockedIssues(t, store) if cached[issueB.ID] { @@ -451,7 +451,7 @@ func TestConditionalBlocksVariousFailureKeywords(t *testing.T) { store.AddDependency(ctx, dep, "test-user") // Close A with failure reason - store.CloseIssue(ctx, issueA.ID, "Closed: "+reason, "test-user") + store.CloseIssue(ctx, issueA.ID, "Closed: "+reason, "test-user", "") cached := getCachedBlockedIssues(t, store) if cached[issueB.ID] { @@ -501,7 +501,7 @@ func TestWaitsForAllChildren(t *testing.T) { } // Close first child - waiter should still be blocked (second child still open) - store.CloseIssue(ctx, child1.ID, "Done", "test-user") + store.CloseIssue(ctx, child1.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if !cached[waiter.ID] { @@ -509,7 +509,7 @@ func TestWaitsForAllChildren(t *testing.T) { } // Close second child - waiter should now be unblocked - store.CloseIssue(ctx, child2.ID, "Done", "test-user") + store.CloseIssue(ctx, child2.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if cached[waiter.ID] { @@ -557,7 +557,7 @@ func TestWaitsForAnyChildren(t *testing.T) { } // Close first child - waiter should now be unblocked (any-children gate satisfied) - store.CloseIssue(ctx, child1.ID, "Done", "test-user") + store.CloseIssue(ctx, child1.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if cached[waiter.ID] { @@ -636,7 +636,7 @@ func TestWaitsForDynamicChildrenAdded(t *testing.T) { } // Close the child - waiter should be unblocked again - store.CloseIssue(ctx, child.ID, "Done", "test-user") + store.CloseIssue(ctx, child.ID, "Done", "test-user", "") cached = getCachedBlockedIssues(t, store) if cached[waiter.ID] { diff --git a/internal/storage/sqlite/epics_test.go b/internal/storage/sqlite/epics_test.go index 37a7bfd0..e8b176a4 100644 --- a/internal/storage/sqlite/epics_test.go +++ b/internal/storage/sqlite/epics_test.go @@ -57,7 +57,7 @@ func (h *epicTestHelper) addParentChildDependency(childID, parentID string) { } func (h *epicTestHelper) closeIssue(id, reason string) { - if err := h.store.CloseIssue(h.ctx, id, reason, "test-user"); err != nil { + if err := h.store.CloseIssue(h.ctx, id, reason, "test-user", ""); err != nil { h.t.Fatalf("CloseIssue (%s) failed: %v", id, err) } } diff --git a/internal/storage/sqlite/events_test.go b/internal/storage/sqlite/events_test.go index cda9f273..00191365 100644 --- a/internal/storage/sqlite/events_test.go +++ b/internal/storage/sqlite/events_test.go @@ -311,7 +311,7 @@ func TestEventTypesInHistory(t *testing.T) { t.Fatalf("AddLabel failed: %v", err) } - err = store.CloseIssue(ctx, issue.ID, "Done", "test-user") + err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "") if err != nil { t.Fatalf("CloseIssue failed: %v", err) } diff --git a/internal/storage/sqlite/graph_links_test.go b/internal/storage/sqlite/graph_links_test.go index b81a58c1..d3c42dab 100644 --- a/internal/storage/sqlite/graph_links_test.go +++ b/internal/storage/sqlite/graph_links_test.go @@ -179,7 +179,7 @@ func TestDuplicateOf(t *testing.T) { } // Close the duplicate - if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test"); err != nil { + if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test", ""); err != nil { t.Fatalf("Failed to close duplicate: %v", err) } @@ -251,7 +251,7 @@ func TestSupersededBy(t *testing.T) { } // Close old version - if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test"); err != nil { + if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test", ""); err != nil { t.Fatalf("Failed to close old version: %v", err) } diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index 0d3c217c..552eb819 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -50,6 +50,7 @@ var migrationsList = []Migration{ {"mol_type_column", migrations.MigrateMolTypeColumn}, {"hooked_status_migration", migrations.MigrateHookedStatus}, {"event_fields", migrations.MigrateEventFields}, + {"closed_by_session_column", migrations.MigrateClosedBySessionColumn}, } // MigrationInfo contains metadata about a migration for inspection @@ -107,6 +108,7 @@ func getMigrationDescription(name string) string { "mol_type_column": "Adds mol_type column for molecule type classification (swarm/patrol/work)", "hooked_status_migration": "Migrates blocked hooked issues to in_progress status", "event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads", + "closed_by_session_column": "Adds closed_by_session column for tracking which Claude Code session closed an issue", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/034_closed_by_session_column.go b/internal/storage/sqlite/migrations/034_closed_by_session_column.go new file mode 100644 index 00000000..f8402a64 --- /dev/null +++ b/internal/storage/sqlite/migrations/034_closed_by_session_column.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateClosedBySessionColumn adds the closed_by_session column to the issues table. +// This tracks which Claude Code session closed the issue, enabling work attribution +// for entity CV building. See Gas Town decision 009-session-events-architecture.md. +func MigrateClosedBySessionColumn(db *sql.DB) error { + // Check if column already exists + var columnExists bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('issues') + WHERE name = 'closed_by_session' + `).Scan(&columnExists) + if err != nil { + return fmt.Errorf("failed to check closed_by_session column: %w", err) + } + + if columnExists { + return nil + } + + // Add the closed_by_session column + _, err = db.Exec(`ALTER TABLE issues ADD COLUMN closed_by_session TEXT DEFAULT ''`) + if err != nil { + return fmt.Errorf("failed to add closed_by_session column: %w", err) + } + + return nil +} diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 1b8102a8..1068d495 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -664,6 +664,8 @@ var allowedUpdateFields = map[string]bool{ "estimated_minutes": true, "external_ref": true, "closed_at": true, + "close_reason": true, + "closed_by_session": true, // Messaging fields "sender": true, "wisp": true, // Database column is 'ephemeral', mapped in UpdateIssue @@ -1070,8 +1072,9 @@ func (s *SQLiteStorage) ResetCounter(ctx context.Context, prefix string) error { return nil } -// CloseIssue closes an issue with a reason -func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error { +// CloseIssue closes an issue with a reason. +// The session parameter tracks which Claude Code session closed the issue (can be empty). +func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error { now := time.Now() // Update with special event handling @@ -1086,9 +1089,9 @@ func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string // 2. events.comment - for audit history (when was it closed, by whom) // Keep both in sync. If refactoring, consider deriving one from the other. result, err := tx.ExecContext(ctx, ` - UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ? + UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?, closed_by_session = ? WHERE id = ? - `, types.StatusClosed, now, now, reason, id) + `, types.StatusClosed, now, now, reason, session, id) if err != nil { return fmt.Errorf("failed to close issue: %w", err) } diff --git a/internal/storage/sqlite/ready_test.go b/internal/storage/sqlite/ready_test.go index f2e2013f..453d8fdb 100644 --- a/internal/storage/sqlite/ready_test.go +++ b/internal/storage/sqlite/ready_test.go @@ -515,7 +515,7 @@ func TestDeepHierarchyBlocking(t *testing.T) { } // Now close the blocker and verify all levels become ready - store.CloseIssue(ctx, blocker.ID, "Done", "test-user") + store.CloseIssue(ctx, blocker.ID, "Done", "test-user", "") ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) if err != nil { @@ -564,7 +564,7 @@ func TestGetReadyWorkIncludesInProgress(t *testing.T) { store.UpdateIssue(ctx, issue3.ID, map[string]interface{}{"status": types.StatusInProgress}, "test-user") store.CreateIssue(ctx, issue4, "test-user") store.CreateIssue(ctx, issue5, "test-user") - store.CloseIssue(ctx, issue5.ID, "Done", "test-user") + store.CloseIssue(ctx, issue5.ID, "Done", "test-user", "") // Add dependency: issue3 blocks on issue4 store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user") @@ -1018,7 +1018,7 @@ func TestGetReadyWorkExternalDeps(t *testing.T) { } // Close the capability issue - if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user"); err != nil { + if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil { t.Fatalf("failed to close capability issue: %v", err) } @@ -1246,7 +1246,7 @@ func TestGetBlockedIssuesFiltersExternalDeps(t *testing.T) { } // Close the capability issue - if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user"); err != nil { + if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil { t.Fatalf("failed to close capability issue: %v", err) } @@ -1371,7 +1371,7 @@ func TestGetBlockedIssuesPartialExternalDeps(t *testing.T) { if err := externalStore.AddLabel(ctx, cap1Issue.ID, "provides:cap1", "test-user"); err != nil { t.Fatalf("failed to add provides label: %v", err) } - if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user"); err != nil { + if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user", ""); err != nil { t.Fatalf("failed to close cap1 issue: %v", err) } diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 49bd3acb..335df04d 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS issues ( created_by TEXT DEFAULT '', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, closed_at DATETIME, + closed_by_session TEXT DEFAULT '', external_ref TEXT, compaction_level INTEGER DEFAULT 0, compacted_at DATETIME, diff --git a/internal/storage/sqlite/sqlite_bench_test.go b/internal/storage/sqlite/sqlite_bench_test.go index 8eb6d7bb..b1f1fc57 100644 --- a/internal/storage/sqlite/sqlite_bench_test.go +++ b/internal/storage/sqlite/sqlite_bench_test.go @@ -180,7 +180,7 @@ func BenchmarkBulkCloseIssues(b *testing.B) { for i := 0; i < b.N; i++ { for j, issue := range issues { - if err := store.CloseIssue(ctx, issue.ID, "Bulk closed", "bench"); err != nil { + if err := store.CloseIssue(ctx, issue.ID, "Bulk closed", "bench", ""); err != nil { b.Fatalf("CloseIssue failed: %v", err) } // Re-open for next iteration (except last one) diff --git a/internal/storage/sqlite/sqlite_test.go b/internal/storage/sqlite/sqlite_test.go index 87753c72..f469bab2 100644 --- a/internal/storage/sqlite/sqlite_test.go +++ b/internal/storage/sqlite/sqlite_test.go @@ -640,7 +640,7 @@ func TestCloseIssue(t *testing.T) { t.Fatalf("CreateIssue failed: %v", err) } - err = store.CloseIssue(ctx, issue.ID, "Done", "test-user") + err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "") if err != nil { t.Fatalf("CloseIssue failed: %v", err) } @@ -717,7 +717,7 @@ func TestClosedAtInvariant(t *testing.T) { } // Close the issue - err = store.CloseIssue(ctx, issue.ID, "Done", "test-user") + err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "") if err != nil { t.Fatalf("CloseIssue failed: %v", err) } @@ -812,7 +812,7 @@ func TestSearchIssues(t *testing.T) { } // Close the third issue if issue.Title == "Another bug" { - err = store.CloseIssue(ctx, issue.ID, "Done", "test-user") + err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "") if err != nil { t.Fatalf("CloseIssue failed: %v", err) } @@ -977,7 +977,7 @@ func TestGetStatistics(t *testing.T) { } // Close the one that should be closed if issue.Title == "Closed task" { - err = store.CloseIssue(ctx, issue.ID, "Done", "test-user") + err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "") if err != nil { t.Fatalf("CloseIssue failed: %v", err) } @@ -1532,7 +1532,7 @@ func TestConvoyReactiveCompletion(t *testing.T) { } // Close first issue - convoy should still be open - err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user") + err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user", "") if err != nil { t.Fatalf("CloseIssue issue1 failed: %v", err) } @@ -1546,7 +1546,7 @@ func TestConvoyReactiveCompletion(t *testing.T) { } // Close second issue - convoy should auto-close now - err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user") + err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user", "") if err != nil { t.Fatalf("CloseIssue issue2 failed: %v", err) } diff --git a/internal/storage/sqlite/test_helpers.go b/internal/storage/sqlite/test_helpers.go index 2371f350..fa5bbc97 100644 --- a/internal/storage/sqlite/test_helpers.go +++ b/internal/storage/sqlite/test_helpers.go @@ -105,7 +105,7 @@ func (e *testEnv) AddParentChild(child, parent *types.Issue) { // Close closes the issue with the given reason. func (e *testEnv) Close(issue *types.Issue, reason string) { e.t.Helper() - if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user"); err != nil { + if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user", ""); err != nil { e.t.Fatalf("CloseIssue(%s) failed: %v", issue.ID, err) } } diff --git a/internal/storage/sqlite/tombstone_test.go b/internal/storage/sqlite/tombstone_test.go index 34020a12..17e0fed4 100644 --- a/internal/storage/sqlite/tombstone_test.go +++ b/internal/storage/sqlite/tombstone_test.go @@ -72,7 +72,7 @@ func TestCreateTombstone(t *testing.T) { } // Close the issue to set closed_at - if err := store.CloseIssue(ctx, "bd-closed-1", "closing for test", "tester"); err != nil { + if err := store.CloseIssue(ctx, "bd-closed-1", "closing for test", "tester", ""); err != nil { t.Fatalf("Failed to close issue: %v", err) } diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index 5dd99a4a..de37299b 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -523,13 +523,14 @@ func applyUpdatesToIssue(issue *types.Issue, updates map[string]interface{}) { // CloseIssue closes an issue within the transaction. // NOTE: close_reason is stored in both issues table and events table - see SQLiteStorage.CloseIssue. -func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error { +// The session parameter tracks which Claude Code session closed the issue (can be empty). +func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error { now := time.Now() result, err := t.conn.ExecContext(ctx, ` - UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ? + UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?, closed_by_session = ? WHERE id = ? - `, types.StatusClosed, now, now, reason, id) + `, types.StatusClosed, now, now, reason, session, id) if err != nil { return fmt.Errorf("failed to close issue: %w", err) } diff --git a/internal/storage/sqlite/transaction_test.go b/internal/storage/sqlite/transaction_test.go index 282dbd31..1c2d1959 100644 --- a/internal/storage/sqlite/transaction_test.go +++ b/internal/storage/sqlite/transaction_test.go @@ -254,7 +254,7 @@ func TestTransactionCloseIssue(t *testing.T) { // Close in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { - return tx.CloseIssue(ctx, issue.ID, "Done", "test-actor") + return tx.CloseIssue(ctx, issue.ID, "Done", "test-actor", "") }) if err != nil { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 59914cba..0635219e 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -50,7 +50,7 @@ type Transaction interface { CreateIssue(ctx context.Context, issue *types.Issue, actor string) error CreateIssues(ctx context.Context, issues []*types.Issue, actor string) error UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error - CloseIssue(ctx context.Context, id string, reason string, actor string) error + CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error DeleteIssue(ctx context.Context, id string) error GetIssue(ctx context.Context, id string) (*types.Issue, error) // For read-your-writes within transaction SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) // For read-your-writes within transaction @@ -83,7 +83,7 @@ type Storage interface { GetIssue(ctx context.Context, id string) (*types.Issue, error) GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error - CloseIssue(ctx context.Context, id string, reason string, actor string) error + CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error DeleteIssue(ctx context.Context, id string) error SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 930f422c..a509a3ad 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -35,7 +35,7 @@ func (m *mockStorage) GetIssueByExternalRef(ctx context.Context, externalRef str func (m *mockStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error { return nil } -func (m *mockStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error { +func (m *mockStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error { return nil } func (m *mockStorage) DeleteIssue(ctx context.Context, id string) error { @@ -213,7 +213,7 @@ func (m *mockTransaction) CreateIssues(ctx context.Context, issues []*types.Issu func (m *mockTransaction) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error { return nil } -func (m *mockTransaction) CloseIssue(ctx context.Context, id string, reason string, actor string) error { +func (m *mockTransaction) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error { return nil } func (m *mockTransaction) DeleteIssue(ctx context.Context, id string) error { diff --git a/internal/types/types.go b/internal/types/types.go index fcbe123a..bba71af1 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -36,8 +36,9 @@ type Issue struct { CreatedAt time.Time `json:"created_at"` CreatedBy string `json:"created_by,omitempty"` // Who created this issue (GH#748) UpdatedAt time.Time `json:"updated_at"` - ClosedAt *time.Time `json:"closed_at,omitempty"` - CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing + ClosedAt *time.Time `json:"closed_at,omitempty"` + CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing + ClosedBySession string `json:"closed_by_session,omitempty"` // Claude Code session that closed this issue // ===== External Integration ===== ExternalRef *string `json:"external_ref,omitempty"` // e.g., "gh-9", "jira-ABC"