Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 4 additions & 70 deletions cmd/entire/cli/hooks_claudecode_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -774,80 +774,14 @@ func handlePreTask() error {
// Log context to stdout
logPreTaskHookContext(os.Stdout, input)

// Capture pre-task state
// Capture pre-task state locally (for computing new files when task completes).
// We don't create a shadow branch commit here. Commits are created during
// task completion (handlePostTask/handlePostTodo) only if the task resulted
// in file changes.
if err := CapturePreTaskState(input.ToolUseID); err != nil {
return fmt.Errorf("failed to capture pre-task state: %w", err)
}

// Create "Starting agent" checkpoint
// This allows rewinding to the state just before the subagent began
if err := createStartingAgentCheckpoint(input); err != nil {
// Log warning but don't fail the hook - state was already captured
fmt.Fprintf(os.Stderr, "Warning: failed to create starting checkpoint: %v\n", err)
}

return nil
}

// createStartingAgentCheckpoint creates a checkpoint commit marking the start of a subagent.
// This is called during PreToolUse[Task] hook.
func createStartingAgentCheckpoint(input *TaskHookInput) error {
// Get git author
author, err := GetGitAuthor()
if err != nil {
return fmt.Errorf("failed to get git author: %w", err)
}

// Get the active strategy
strat := GetStrategy()

// Ensure strategy setup is complete
if err := strat.EnsureSetup(); err != nil {
return fmt.Errorf("failed to ensure strategy setup: %w", err)
}

entireSessionID := currentSessionIDWithFallback(input.SessionID)

// Extract subagent type and description from tool_input for descriptive commit messages
subagentType, taskDescription := ParseSubagentTypeAndDescription(input.ToolInput)

// Get agent type from session state
var agentType agent.AgentType
if sessionState, loadErr := strategy.LoadSessionState(entireSessionID); loadErr == nil && sessionState != nil {
agentType = sessionState.AgentType
}

// Build task checkpoint context for the "starting" checkpoint
ctx := strategy.TaskCheckpointContext{
SessionID: entireSessionID,
ToolUseID: input.ToolUseID,
TranscriptPath: input.TranscriptPath,
AuthorName: author.Name,
AuthorEmail: author.Email,
SubagentType: subagentType,
TaskDescription: taskDescription,
AgentType: agentType,
// No file changes yet - this is the starting state
ModifiedFiles: nil,
NewFiles: nil,
DeletedFiles: nil,
// Mark as starting checkpoint (sequence 0)
IsIncremental: true,
IncrementalSequence: 0,
IncrementalType: strategy.IncrementalTypeTaskStart,
}

// Save the checkpoint
if err := strat.SaveTaskCheckpoint(ctx); err != nil {
return fmt.Errorf("failed to save starting checkpoint: %w", err)
}

shortID := input.ToolUseID
if len(shortID) > 12 {
shortID = shortID[:12]
}
fmt.Fprintf(os.Stderr, "[entire] Created starting checkpoint for task %s\n", shortID)

return nil
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/entire/cli/integration_test/default_branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ func TestDefaultBranch_PostTaskWorksOnMain(t *testing.T) {
}

points := env.GetRewindPoints()
if len(points) != 2 {
t.Errorf("expected 2 rewind points (starting + completed checkpoints) on main for %s, got %d", strategyName, len(points))
if len(points) != 1 {
t.Errorf("expected 1 rewind point (completed checkpoint) on main for %s, got %d", strategyName, len(points))
}
})
}
26 changes: 6 additions & 20 deletions cmd/entire/cli/strategy/auto_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,14 +578,12 @@ func (s *AutoCommitStrategy) SaveTaskCheckpoint(ctx TaskCheckpointContext) error

// commitTaskCodeToActive commits task code changes to the active branch.
// Adds an Entire-Checkpoint trailer for metadata lookup that survives amend/rebase.
// For TaskStart checkpoints, creates an empty marker commit even without file changes.
// Skips commit creation if there are no file changes.
func (s *AutoCommitStrategy) commitTaskCodeToActive(repo *git.Repository, ctx TaskCheckpointContext, checkpointID id.CheckpointID) (plumbing.Hash, error) {
// For TaskStart, we want to create a marker commit even without file changes
isTaskStart := ctx.IsIncremental && ctx.IncrementalType == IncrementalTypeTaskStart
hasFileChanges := len(ctx.ModifiedFiles) > 0 || len(ctx.NewFiles) > 0 || len(ctx.DeletedFiles) > 0

// If no file changes and not a TaskStart, skip code commit
if !hasFileChanges && !isTaskStart {
// If no file changes, skip code commit
if !hasFileChanges {
fmt.Fprintf(os.Stderr, "No code changes to commit for task checkpoint\n")
// Return current HEAD hash so metadata can still be stored
head, err := repo.Head()
Expand Down Expand Up @@ -632,21 +630,9 @@ func (s *AutoCommitStrategy) commitTaskCodeToActive(repo *git.Repository, ctx Ta
When: time.Now(),
}

var commitHash plumbing.Hash
if isTaskStart {
// For TaskStart, allow empty commits (marker commits)
commitHash, err = worktree.Commit(commitMsg, &git.CommitOptions{
Author: author,
AllowEmptyCommits: true,
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to create TaskStart marker commit: %w", err)
}
} else {
commitHash, err = commitOrHead(repo, worktree, commitMsg, author)
if err != nil {
return plumbing.ZeroHash, err
}
commitHash, err := commitOrHead(repo, worktree, commitMsg, author)
if err != nil {
return plumbing.ZeroHash, err
}

if ctx.IsIncremental {
Expand Down
91 changes: 6 additions & 85 deletions cmd/entire/cli/strategy/auto_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,86 +357,7 @@ func TestAutoCommitStrategy_SaveTaskCheckpoint_CommitHasMetadataRef(t *testing.T
}
}

func TestAutoCommitStrategy_SaveTaskCheckpoint_TaskStartCreatesEmptyCommit(t *testing.T) {
// Setup temp git repo
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("failed to init git repo: %v", err)
}

// Create initial commit (needed for a valid repo state)
worktree, err := repo.Worktree()
if err != nil {
t.Fatalf("failed to get worktree: %v", err)
}
readmeFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
t.Fatalf("failed to write README: %v", err)
}
if _, err := worktree.Add("README.md"); err != nil {
t.Fatalf("failed to add README: %v", err)
}
initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{
Author: &object.Signature{Name: "Test", Email: "test@test.com"},
})
if err != nil {
t.Fatalf("failed to commit: %v", err)
}

t.Chdir(dir)

// Setup strategy
s := NewAutoCommitStrategy()
if err := s.EnsureSetup(); err != nil {
t.Fatalf("EnsureSetup() error = %v", err)
}

// Create a TaskStart checkpoint with NO file changes
ctx := TaskCheckpointContext{
SessionID: "test-session-taskstart",
ToolUseID: "toolu_taskstart123",
IsIncremental: true,
IncrementalType: IncrementalTypeTaskStart,
IncrementalSequence: 1,
SubagentType: "dev",
TaskDescription: "Implement feature",
// No file changes
ModifiedFiles: []string{},
NewFiles: []string{},
DeletedFiles: []string{},
AuthorName: "Test",
AuthorEmail: "test@test.com",
}

if err := s.SaveTaskCheckpoint(ctx); err != nil {
t.Fatalf("SaveTaskCheckpoint() error = %v", err)
}

// Verify a NEW commit was created (not just returning HEAD)
head, err := repo.Head()
if err != nil {
t.Fatalf("failed to get HEAD: %v", err)
}

if head.Hash() == initialCommit {
t.Error("TaskStart should create a new commit even without file changes, but HEAD is still the initial commit")
}

// Verify the commit message contains the expected content
commit, err := repo.CommitObject(head.Hash())
if err != nil {
t.Fatalf("failed to get HEAD commit: %v", err)
}

// TaskStart commits should have "Starting" in the message
expectedSubstring := "Starting 'dev' agent: Implement feature"
if !strings.Contains(commit.Message, expectedSubstring) {
t.Errorf("commit message should contain %q, got:\n%s", expectedSubstring, commit.Message)
}
}

func TestAutoCommitStrategy_SaveTaskCheckpoint_NonTaskStartNoChangesAmendedForMetadata(t *testing.T) {
func TestAutoCommitStrategy_SaveTaskCheckpoint_NoChangesSkipsCommit(t *testing.T) {
// Setup temp git repo
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
Expand Down Expand Up @@ -471,12 +392,12 @@ func TestAutoCommitStrategy_SaveTaskCheckpoint_NonTaskStartNoChangesAmendedForMe
t.Fatalf("EnsureSetup() error = %v", err)
}

// Create a regular incremental checkpoint (not TaskStart) with NO file changes
// Create an incremental checkpoint with NO file changes
ctx := TaskCheckpointContext{
SessionID: "test-session-nontaskstart",
ToolUseID: "toolu_nontaskstart456",
SessionID: "test-session-nochanges",
ToolUseID: "toolu_nochanges456",
IsIncremental: true,
IncrementalType: "TodoWrite", // NOT TaskStart
IncrementalType: "TodoWrite",
IncrementalSequence: 2,
TodoContent: "Write some code",
// No file changes
Expand Down Expand Up @@ -512,7 +433,7 @@ func TestAutoCommitStrategy_SaveTaskCheckpoint_NonTaskStartNoChangesAmendedForMe

// The tree hash should be the same (no file changes)
if newCommit.TreeHash != oldCommit.TreeHash {
t.Error("non-TaskStart checkpoint without file changes should have the same tree hash")
t.Error("checkpoint without file changes should have the same tree hash")
}

// Metadata should still be stored on entire/sessions branch
Expand Down
23 changes: 6 additions & 17 deletions cmd/entire/cli/strategy/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,6 @@ func TruncateDescription(s string, maxLen int) string {
return s[:maxLen-3] + "..."
}

// FormatSubagentStartMessage formats a commit message for when a subagent starts.
// Format: "Starting '<agent-type>' agent: <description> (<tool-use-id>)"
//
// Edge cases:
// - Empty description: "Starting '<agent-type>' agent (<tool-use-id>)"
// - Empty agentType: "Starting agent: <description> (<tool-use-id>)"
// - Both empty: "Task: <tool-use-id>"
func FormatSubagentStartMessage(agentType, description, toolUseID string) string {
return formatSubagentMessage("Starting", agentType, description, toolUseID)
}

// FormatSubagentEndMessage formats a commit message for when a subagent completes.
// Format: "Completed '<agent-type>' agent: <description> (<tool-use-id>)"
//
Expand Down Expand Up @@ -67,10 +56,11 @@ func formatSubagentMessage(verb, agentType, description, toolUseID string) strin
}

// FormatIncrementalSubject formats the commit message subject for incremental checkpoints.
// Handles both TaskStart (starting agent) and regular incremental (TodoWrite) checkpoints.
// Delegates to FormatIncrementalMessage.
//
// For TaskStart: delegates to FormatSubagentStartMessage
// For other types: delegates to FormatIncrementalMessage
// Note: The incrementalType, subagentType, and taskDescription parameters are kept for
// API compatibility but are not currently used. They may be used in the future for
// different checkpoint types.
func FormatIncrementalSubject(
incrementalType string,
subagentType string,
Expand All @@ -79,9 +69,8 @@ func FormatIncrementalSubject(
incrementalSequence int,
shortToolUseID string,
) string {
if incrementalType == IncrementalTypeTaskStart {
return FormatSubagentStartMessage(subagentType, taskDescription, shortToolUseID)
}
// Currently all incremental checkpoints use the same format
_, _, _ = incrementalType, subagentType, taskDescription // Silence unused warnings
return FormatIncrementalMessage(todoContent, incrementalSequence, shortToolUseID)
}

Expand Down
76 changes: 2 additions & 74 deletions cmd/entire/cli/strategy/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,62 +51,6 @@ func TestTruncateDescription(t *testing.T) {
}
}

func TestFormatSubagentStartMessage(t *testing.T) {
tests := []struct {
name string
agentType string
description string
toolUseID string
want string
}{
{
name: "full message with all fields",
agentType: "dev",
description: "Implement user authentication",
toolUseID: "toolu_019t1c",
want: "Starting 'dev' agent: Implement user authentication (toolu_019t1c)",
},
{
name: "empty description",
agentType: "dev",
description: "",
toolUseID: "toolu_019t1c",
want: "Starting 'dev' agent (toolu_019t1c)",
},
{
name: "empty agent type",
agentType: "",
description: "Implement user authentication",
toolUseID: "toolu_019t1c",
want: "Starting agent: Implement user authentication (toolu_019t1c)",
},
{
name: "both empty",
agentType: "",
description: "",
toolUseID: "toolu_019t1c",
want: "Task: toolu_019t1c",
},
{
name: "long description truncated",
agentType: "dev",
description: "This is a very long description that should be truncated to fit within the limit",
toolUseID: "toolu_019t1c",
want: "Starting 'dev' agent: This is a very long description that should be truncated ... (toolu_019t1c)",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatSubagentStartMessage(tt.agentType, tt.description, tt.toolUseID)
if got != tt.want {
t.Errorf("FormatSubagentStartMessage(%q, %q, %q) = %q, want %q",
tt.agentType, tt.description, tt.toolUseID, got, tt.want)
}
})
}
}

func TestFormatSubagentEndMessage(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -316,31 +260,15 @@ func TestFormatIncrementalSubject(t *testing.T) {
want string
}{
{
name: "TaskStart with full details",
incrementalType: IncrementalTypeTaskStart,
subagentType: "dev",
taskDescription: "Implement user authentication",
shortToolUseID: "toolu_019t1c",
want: "Starting 'dev' agent: Implement user authentication (toolu_019t1c)",
},
{
name: "TaskStart with empty description",
incrementalType: IncrementalTypeTaskStart,
subagentType: "dev",
taskDescription: "",
shortToolUseID: "toolu_019t1c",
want: "Starting 'dev' agent (toolu_019t1c)",
},
{
name: "Regular incremental with todo content",
name: "incremental with todo content",
incrementalType: "TodoWrite",
todoContent: "Set up Node.js project",
incrementalSequence: 1,
shortToolUseID: "toolu_01CJhrr",
want: "Set up Node.js project (toolu_01CJhrr)",
},
{
name: "Regular incremental without todo content",
name: "incremental without todo content",
incrementalType: "TodoWrite",
todoContent: "",
incrementalSequence: 3,
Expand Down
Loading