From f591afd8b2c667b42f3611631fdbe889a26c4059 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 27 Feb 2026 14:25:50 +1100 Subject: [PATCH 1/4] fix: use agent type in shadow branch fallback commit message generateCommitMessage() hardcoded "Claude Code session updates" as the fallback when no prompt was available. This affected agents like Cursor that don't implement TranscriptAnalyzer. Now accepts an agentType param and uses it in the fallback (e.g. "Cursor session updates"). Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 749feac02cb6 --- cmd/entire/cli/commit_message.go | 12 +++- cmd/entire/cli/commit_message_test.go | 79 +++++++++++++++++++-------- cmd/entire/cli/lifecycle.go | 2 +- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/cmd/entire/cli/commit_message.go b/cmd/entire/cli/commit_message.go index 3d3e0f77d..d8b91d99e 100644 --- a/cmd/entire/cli/commit_message.go +++ b/cmd/entire/cli/commit_message.go @@ -1,13 +1,16 @@ package cli import ( + "fmt" "strings" + "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/stringutil" ) -// generateCommitMessage creates a commit message from the user's original prompt -func generateCommitMessage(originalPrompt string) string { +// generateCommitMessage creates a commit message from the user's original prompt. +// If the prompt is empty or cleans to empty, falls back to " session updates". +func generateCommitMessage(originalPrompt string, agentType agent.AgentType) string { if originalPrompt != "" { cleaned := cleanPromptForCommit(originalPrompt) if cleaned != "" { @@ -15,7 +18,10 @@ func generateCommitMessage(originalPrompt string) string { } } - return "Claude Code session updates" + if agentType == "" { + agentType = agent.AgentTypeUnknown + } + return fmt.Sprintf("%s session updates", agentType) } // cleanPromptForCommit cleans up a user prompt to make it suitable as a commit message diff --git a/cmd/entire/cli/commit_message_test.go b/cmd/entire/cli/commit_message_test.go index 994bb76c9..5b445772c 100644 --- a/cmd/entire/cli/commit_message_test.go +++ b/cmd/entire/cli/commit_message_test.go @@ -2,6 +2,8 @@ package cli import ( "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) func TestCleanPromptForCommit(t *testing.T) { @@ -185,47 +187,78 @@ func TestCleanPromptForCommit(t *testing.T) { func TestGenerateCommitMessage(t *testing.T) { tests := []struct { - name string - prompt string - expected string + name string + prompt string + agentType agent.AgentType + expected string }{ { - name: "returns cleaned prompt", - prompt: "Can you fix the login bug?", - expected: "Fix the login bug", + name: "returns cleaned prompt", + prompt: "Can you fix the login bug?", + agentType: agent.AgentTypeClaudeCode, + expected: "Fix the login bug", + }, + { + name: "returns default for empty prompt with Claude Code", + prompt: "", + agentType: agent.AgentTypeClaudeCode, + expected: "Claude Code session updates", + }, + { + name: "returns default when cleaned prompt is empty", + prompt: "Can you ?", + agentType: agent.AgentTypeClaudeCode, + expected: "Claude Code session updates", + }, + { + name: "returns default for whitespace only prompt", + prompt: " ", + agentType: agent.AgentTypeClaudeCode, + expected: "Claude Code session updates", + }, + { + name: "handles direct command prompt", + prompt: "Add unit tests for the auth module", + agentType: agent.AgentTypeClaudeCode, + expected: "Add unit tests for the auth module", }, { - name: "returns default for empty prompt", - prompt: "", - expected: "Claude Code session updates", + name: "handles polite request", + prompt: "Please refactor the database connection handling", + agentType: agent.AgentTypeClaudeCode, + expected: "Refactor the database connection handling", }, { - name: "returns default when cleaned prompt is empty", - prompt: "Can you ?", - expected: "Claude Code session updates", + name: "returns Cursor fallback for empty prompt", + prompt: "", + agentType: agent.AgentTypeCursor, + expected: "Cursor session updates", }, { - name: "returns default for whitespace only prompt", - prompt: " ", - expected: "Claude Code session updates", + name: "returns Gemini CLI fallback for empty prompt", + prompt: "", + agentType: agent.AgentTypeGemini, + expected: "Gemini CLI session updates", }, { - name: "handles direct command prompt", - prompt: "Add unit tests for the auth module", - expected: "Add unit tests for the auth module", + name: "returns OpenCode fallback for empty prompt", + prompt: "", + agentType: agent.AgentTypeOpenCode, + expected: "OpenCode session updates", }, { - name: "handles polite request", - prompt: "Please refactor the database connection handling", - expected: "Refactor the database connection handling", + name: "returns Agent fallback for empty agent type", + prompt: "", + agentType: "", + expected: "Agent session updates", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := generateCommitMessage(tt.prompt) + result := generateCommitMessage(tt.prompt, tt.agentType) if result != tt.expected { - t.Errorf("generateCommitMessage(%q) = %q, want %q", tt.prompt, result, tt.expected) + t.Errorf("generateCommitMessage(%q, %q) = %q, want %q", tt.prompt, tt.agentType, result, tt.expected) } }) } diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 64fe7ca16..7b14cace7 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -270,7 +270,7 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev if len(allPrompts) > 0 { lastPrompt = allPrompts[len(allPrompts)-1] } - commitMessage := generateCommitMessage(lastPrompt) + commitMessage := generateCommitMessage(lastPrompt, ag.Type()) fmt.Fprintf(os.Stderr, "Using commit message: %s\n", commitMessage) // Get worktree root for path normalization From 44a7bae1c67e0eac6301dbc76f944a361c5aa806 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 27 Feb 2026 15:00:51 +1100 Subject: [PATCH 2/4] feat: implement TranscriptAnalyzer for Cursor agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor uses the same JSONL transcript format as Claude Code. Implement the TranscriptAnalyzer interface to enable prompt extraction (for shadow branch commit messages), summary extraction, and transcript position tracking. ExtractModifiedFilesFromOffset returns ErrNoToolUseBlocks since Cursor transcripts lack tool_use blocks — file detection continues to rely on git status. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: b25d9722e24d --- cmd/entire/cli/agent/cursor/cursor.go | 5 +- cmd/entire/cli/agent/cursor/lifecycle.go | 4 - cmd/entire/cli/agent/cursor/transcript.go | 122 +++++++++++ .../cli/agent/cursor/transcript_test.go | 195 ++++++++++++++++++ 4 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 cmd/entire/cli/agent/cursor/transcript.go create mode 100644 cmd/entire/cli/agent/cursor/transcript_test.go diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index 910111a04..00dfc918d 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -103,8 +103,9 @@ func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) { } // ReadSession reads a session from Cursor's storage (JSONL transcript file). -// Note: ModifiedFiles is left empty because Cursor's transcript format does not -// contain tool_use blocks. File detection relies on git status instead. +// Note: ModifiedFiles is left empty because Cursor's transcript does not contain +// tool_use blocks for file detection. TranscriptAnalyzer extracts prompts and +// summaries; file detection relies on git status. func (c *CursorAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { if input.SessionRef == "" { return nil, errors.New("session reference (transcript path) is required") diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 74169ce88..2e6fe2b5c 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -44,10 +44,6 @@ func (c *CursorAgent) ReadTranscript(sessionRef string) ([]byte, error) { return data, nil } -// Note: CursorAgent does NOT implement TranscriptAnalyzer. Cursor's transcript -// format does not contain tool_use blocks that would allow extracting modified -// files. File detection relies on git status instead. - // --- Internal hook parsing functions --- // resolveTranscriptRef returns the transcript path from the hook input, or computes diff --git a/cmd/entire/cli/agent/cursor/transcript.go b/cmd/entire/cli/agent/cursor/transcript.go new file mode 100644 index 000000000..8f772701a --- /dev/null +++ b/cmd/entire/cli/agent/cursor/transcript.go @@ -0,0 +1,122 @@ +package cursor + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/textutil" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// Compile-time interface assertion. +var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil) + +// ErrNoToolUseBlocks indicates that Cursor transcripts do not contain tool_use blocks, +// so file extraction from the transcript is not supported. File detection uses git status. +var ErrNoToolUseBlocks = errors.New("cursor transcripts do not contain tool_use blocks; file detection uses git status") + +// GetTranscriptPosition returns the current line count of a Cursor transcript. +// Cursor uses the same JSONL format as Claude Code, so position is the number of lines. +// Uses bufio.Reader to handle arbitrarily long lines (no size limit). +// Returns 0 if the file doesn't exist or is empty. +func (c *CursorAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec // Path comes from Cursor transcript location + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to open transcript file: %w", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + lineCount := 0 + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + if len(line) > 0 { + lineCount++ // Count final line without trailing newline + } + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractPrompts extracts user prompts from the transcript starting at the given line offset. +// Cursor uses the same JSONL format as Claude Code; the shared transcript package normalizes +// "role" → "type" and strips tags. +func (c *CursorAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + lines, err := transcript.ParseFromFileAtLine(sessionRef, fromOffset) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + var prompts []string + for i := range lines { + if lines[i].Type != transcript.TypeUser { + continue + } + content := transcript.ExtractUserContent(lines[i].Message) + if content != "" { + prompts = append(prompts, textutil.StripIDEContextTags(content)) + } + } + return prompts, nil +} + +// ExtractSummary extracts the last assistant message as a session summary. +func (c *CursorAgent) ExtractSummary(sessionRef string) (string, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return "", fmt.Errorf("failed to read transcript: %w", err) + } + + lines, parseErr := transcript.ParseFromBytes(data) + if parseErr != nil { + return "", fmt.Errorf("failed to parse transcript: %w", parseErr) + } + + // Walk backward to find last assistant text block + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].Type != transcript.TypeAssistant { + continue + } + var msg transcript.AssistantMessage + if err := json.Unmarshal(lines[i].Message, &msg); err != nil { + continue + } + for _, block := range msg.Content { + if block.Type == transcript.ContentTypeText && block.Text != "" { + return block.Text, nil + } + } + } + return "", nil +} + +// ExtractModifiedFilesFromOffset returns ErrNoToolUseBlocks because Cursor transcripts +// do not contain tool_use blocks. File detection relies on git status instead. +// All call sites handle this error gracefully by falling through to git-status-based detection. +func (c *CursorAgent) ExtractModifiedFilesFromOffset(path string, _ int) ([]string, int, error) { + if path == "" { + return nil, 0, nil + } + + return nil, 0, ErrNoToolUseBlocks +} diff --git a/cmd/entire/cli/agent/cursor/transcript_test.go b/cmd/entire/cli/agent/cursor/transcript_test.go new file mode 100644 index 000000000..de87d02d3 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/transcript_test.go @@ -0,0 +1,195 @@ +package cursor + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time interface check. +var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil) + +// --- GetTranscriptPosition --- + +func TestCursorAgent_GetTranscriptPosition(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + pos, err := ag.GetTranscriptPosition(path) + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 4 { + t.Errorf("GetTranscriptPosition() = %d, want 4", pos) + } +} + +func TestCursorAgent_GetTranscriptPosition_EmptyPath(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + pos, err := ag.GetTranscriptPosition("") + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition() = %d, want 0", pos) + } +} + +func TestCursorAgent_GetTranscriptPosition_NonexistentFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + pos, err := ag.GetTranscriptPosition("/nonexistent/path/transcript.jsonl") + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition() = %d, want 0", pos) + } +} + +// --- ExtractPrompts --- + +func TestCursorAgent_ExtractPrompts(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + prompts, err := ag.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + if len(prompts) != 2 { + t.Fatalf("ExtractPrompts() returned %d prompts, want 2", len(prompts)) + } + // Verify tags are stripped + if prompts[0] != "hello" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "hello") + } + if prompts[1] != "add 'one' to a file and commit" { + t.Errorf("prompts[1] = %q, want %q", prompts[1], "add 'one' to a file and commit") + } +} + +func TestCursorAgent_ExtractPrompts_WithOffset(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + // Offset 2 skips the first user+assistant pair, leaving 1 user prompt + prompts, err := ag.ExtractPrompts(path, 2) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + if len(prompts) != 1 { + t.Fatalf("ExtractPrompts() returned %d prompts, want 1", len(prompts)) + } + if prompts[0] != "add 'one' to a file and commit" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "add 'one' to a file and commit") + } +} + +func TestCursorAgent_ExtractPrompts_EmptyFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "empty.jsonl") + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatalf("failed to write empty file: %v", err) + } + + prompts, err := ag.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + if len(prompts) != 0 { + t.Errorf("ExtractPrompts() returned %d prompts, want 0", len(prompts)) + } +} + +// --- ExtractSummary --- + +func TestCursorAgent_ExtractSummary(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + summary, err := ag.ExtractSummary(path) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + if summary != "Created one.txt with one and committed." { + t.Errorf("ExtractSummary() = %q, want %q", summary, "Created one.txt with one and committed.") + } +} + +func TestCursorAgent_ExtractSummary_EmptyFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "empty.jsonl") + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatalf("failed to write empty file: %v", err) + } + + summary, err := ag.ExtractSummary(path) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + if summary != "" { + t.Errorf("ExtractSummary() = %q, want empty string", summary) + } +} + +// --- ExtractModifiedFilesFromOffset --- + +func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + if !errors.Is(err, ErrNoToolUseBlocks) { + t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want ErrNoToolUseBlocks", err) + } + if files != nil { + t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) + } + if pos != 4 { + t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 4", pos) + } +} + +func TestCursorAgent_ExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) + } + if files != nil { + t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) + } + if pos != 0 { + t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos) + } +} From f4fbc1a01bd9434da83892ed3f0f7779cb6b8727 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Fri, 27 Feb 2026 15:11:26 +1100 Subject: [PATCH 3/4] fix: align test with simplified ExtractModifiedFilesFromOffset The implementation returns position 0 (not the line count) since it doesn't read the file. Update the test expectation to match. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: a8c66992dc1b --- cmd/entire/cli/agent/cursor/transcript_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/transcript_test.go b/cmd/entire/cli/agent/cursor/transcript_test.go index de87d02d3..68aa7ddbd 100644 --- a/cmd/entire/cli/agent/cursor/transcript_test.go +++ b/cmd/entire/cli/agent/cursor/transcript_test.go @@ -173,8 +173,8 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) { if files != nil { t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) } - if pos != 4 { - t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 4", pos) + if pos != 0 { + t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos) } } From e7ce44382f022649c378743153476f18a3d962c9 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 27 Feb 2026 15:47:53 +0100 Subject: [PATCH 4/4] cursor: always return 0 extracted files Entire-Checkpoint: 8c2d20b5258d --- cmd/entire/cli/agent/cursor/transcript.go | 17 ++--- .../cli/agent/cursor/transcript_test.go | 5 +- cmd/entire/cli/integration_test/hooks_test.go | 64 +++++++++++++++++++ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/transcript.go b/cmd/entire/cli/agent/cursor/transcript.go index 8f772701a..bb328a60f 100644 --- a/cmd/entire/cli/agent/cursor/transcript.go +++ b/cmd/entire/cli/agent/cursor/transcript.go @@ -3,7 +3,6 @@ package cursor import ( "bufio" "encoding/json" - "errors" "fmt" "io" "os" @@ -16,10 +15,6 @@ import ( // Compile-time interface assertion. var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil) -// ErrNoToolUseBlocks indicates that Cursor transcripts do not contain tool_use blocks, -// so file extraction from the transcript is not supported. File detection uses git status. -var ErrNoToolUseBlocks = errors.New("cursor transcripts do not contain tool_use blocks; file detection uses git status") - // GetTranscriptPosition returns the current line count of a Cursor transcript. // Cursor uses the same JSONL format as Claude Code, so position is the number of lines. // Uses bufio.Reader to handle arbitrarily long lines (no size limit). @@ -110,13 +105,9 @@ func (c *CursorAgent) ExtractSummary(sessionRef string) (string, error) { return "", nil } -// ExtractModifiedFilesFromOffset returns ErrNoToolUseBlocks because Cursor transcripts +// ExtractModifiedFilesFromOffset returns nil, 0, nil because Cursor transcripts // do not contain tool_use blocks. File detection relies on git status instead. -// All call sites handle this error gracefully by falling through to git-status-based detection. -func (c *CursorAgent) ExtractModifiedFilesFromOffset(path string, _ int) ([]string, int, error) { - if path == "" { - return nil, 0, nil - } - - return nil, 0, ErrNoToolUseBlocks +// All call sites are expected to also use git-status-based detection. +func (c *CursorAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]string, int, error) { + return nil, 0, nil } diff --git a/cmd/entire/cli/agent/cursor/transcript_test.go b/cmd/entire/cli/agent/cursor/transcript_test.go index 68aa7ddbd..9ecb378ad 100644 --- a/cmd/entire/cli/agent/cursor/transcript_test.go +++ b/cmd/entire/cli/agent/cursor/transcript_test.go @@ -1,7 +1,6 @@ package cursor import ( - "errors" "os" "path/filepath" "testing" @@ -167,8 +166,8 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) { path := writeSampleTranscript(t, tmpDir) files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) - if !errors.Is(err, ErrNoToolUseBlocks) { - t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want ErrNoToolUseBlocks", err) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err) } if files != nil { t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) diff --git a/cmd/entire/cli/integration_test/hooks_test.go b/cmd/entire/cli/integration_test/hooks_test.go index 6028dbaed..9c834c396 100644 --- a/cmd/entire/cli/integration_test/hooks_test.go +++ b/cmd/entire/cli/integration_test/hooks_test.go @@ -203,6 +203,70 @@ func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { } } +// TestHookRunner_SimulateStop_GitStatusMergesWithTranscript verifies that +// handleLifecycleTurnEnd always consults git status and merges those files with +// transcript-extracted files. This guards against a regression where someone makes +// git status conditional on transcript parsing failing. +// +// Scenario: +// - Transcript references file A (via tool_use block) +// - File B is a tracked file modified on disk but NOT mentioned in transcript +// - After stop, FilesTouched must contain both A and B +func TestHookRunner_SimulateStop_GitStatusMergesWithTranscript(t *testing.T) { + t.Parallel() + env := NewRepoWithCommit(t) + + // Create a tracked file that we'll modify later (not mentioned in transcript). + // It must be committed first so git status reports it as "modified" (not "untracked"). + env.WriteFile("tracked-only.txt", "original content") + env.GitAdd("tracked-only.txt") + env.GitCommit("add tracked file") + + session := env.NewSession() + + // Capture pre-prompt state + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Modify the tracked file on disk (git status will see this as modified) + env.WriteFile("tracked-only.txt", "modified content") + + // Create a file that IS mentioned in the transcript + env.WriteFile("transcript-file.txt", "created by agent") + + // Build transcript that only mentions transcript-file.txt + session.CreateTranscript("Create a file", []FileChange{ + {Path: "transcript-file.txt", Content: "created by agent"}, + }) + + // Simulate stop + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Verify FilesTouched contains BOTH files + state, err := env.GetSessionState(session.ID) + if err != nil { + t.Fatalf("failed to get session state: %v", err) + } + if state == nil { + t.Fatal("session state should exist after checkpoint") + } + + filesTouched := make(map[string]bool) + for _, f := range state.FilesTouched { + filesTouched[f] = true + } + + if !filesTouched["transcript-file.txt"] { + t.Errorf("FilesTouched should contain transcript-extracted file 'transcript-file.txt', got %v", state.FilesTouched) + } + if !filesTouched["tracked-only.txt"] { + t.Errorf("FilesTouched should contain git-status-detected file 'tracked-only.txt', got %v", state.FilesTouched) + } +} + // TestUserPromptSubmit_ReinstallsOverwrittenHooks verifies that EnsureSetup is called // during user-prompt-submit (start of turn) and reinstalls hooks that were overwritten // by third-party tools like lefthook. This ensures hooks are in place before any