diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 515da67e4..846d9e8e2 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -206,6 +206,19 @@ type TestOnly interface { IsTestOnly() bool } +// SessionBaseDirProvider is implemented by agents that store transcripts in a +// home-directory-based structure with per-project subdirectories. This enables +// cross-project transcript search (e.g., when a session was started from a +// different working directory). Agents with ephemeral/temp-based storage or +// flat session layouts should NOT implement this interface. +type SessionBaseDirProvider interface { + Agent + + // GetSessionBaseDir returns the base directory containing per-project + // session subdirectories (e.g., ~/.claude/projects, ~/.gemini/tmp). + GetSessionBaseDir() (string, error) +} + // SubagentAwareExtractor provides methods for extracting files and tokens including subagents. // Agents that support spawning subagents (like Claude Code's Task tool) should implement this // to ensure subagent contributions are included in checkpoints. diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index c490b32dc..3b302dc1d 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -140,6 +140,20 @@ func AsPromptExtractor(ag Agent) (PromptExtractor, bool) { //nolint:ireturn // t return pe, true } +// AsSessionBaseDirProvider returns the agent as SessionBaseDirProvider if it implements +// the interface. No capability declaration is needed since this is a built-in-only feature +// (external agents use the agent binary's own session resolution). +func AsSessionBaseDirProvider(ag Agent) (SessionBaseDirProvider, bool) { //nolint:ireturn // type-assertion helper must return interface + if ag == nil { + return nil, false + } + sbp, ok := ag.(SessionBaseDirProvider) + if !ok { + return nil, false + } + return sbp, true +} + // AsSubagentAwareExtractor returns the agent as SubagentAwareExtractor if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. func AsSubagentAwareExtractor(ag Agent) (SubagentAwareExtractor, bool) { //nolint:ireturn // type-assertion helper must return interface diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index d65854673..fe0bf6474 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -104,6 +104,17 @@ func (c *ClaudeCodeAgent) GetSessionDir(repoPath string) (string, error) { return filepath.Join(homeDir, ".claude", "projects", projectDir), nil } +// GetSessionBaseDir returns the base directory containing per-project session subdirectories. +// Unlike GetSessionDir, this does NOT use ENTIRE_TEST_CLAUDE_PROJECT_DIR because the +// test override points to a specific project dir, not the base containing all projects. +func (c *ClaudeCodeAgent) GetSessionBaseDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".claude", "projects"), nil +} + // ReadSession reads a session from Claude's storage (JSONL transcript file). // The session data is stored in NativeData as raw JSONL bytes. // ModifiedFiles is computed by parsing the transcript. diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index c507bfc38..e75f88c51 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -103,6 +103,17 @@ func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) { return filepath.Join(homeDir, ".cursor", "projects", projectDir, "agent-transcripts"), nil } +// GetSessionBaseDir returns the base directory containing per-project session subdirectories. +// Unlike GetSessionDir, this does NOT use test overrides because the override +// points to a specific project dir, not the base containing all projects. +func (c *CursorAgent) GetSessionBaseDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".cursor", "projects"), nil +} + // ReadSession reads a session from Cursor's storage (JSONL transcript file). // Note: ModifiedFiles is left empty because Cursor's transcript does not contain // tool_use blocks for file detection. TranscriptAnalyzer extracts prompts and diff --git a/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go index 862d84aae..a563ac8ab 100644 --- a/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go +++ b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go @@ -107,6 +107,17 @@ func (f *FactoryAIDroidAgent) GetSessionDir(repoPath string) (string, error) { return filepath.Join(homeDir, ".factory", "sessions", projectDir), nil } +// GetSessionBaseDir returns the base directory containing per-project session subdirectories. +// Unlike GetSessionDir, this does NOT use test overrides because the override +// points to a specific project dir, not the base containing all projects. +func (f *FactoryAIDroidAgent) GetSessionBaseDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".factory", "sessions"), nil +} + // ResolveSessionFile returns the path to a Factory AI Droid session file. func (f *FactoryAIDroidAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { return filepath.Join(sessionDir, agentSessionID+".jsonl") diff --git a/cmd/entire/cli/agent/geminicli/gemini.go b/cmd/entire/cli/agent/geminicli/gemini.go index 0cbc92ba4..eef6ee51f 100644 --- a/cmd/entire/cli/agent/geminicli/gemini.go +++ b/cmd/entire/cli/agent/geminicli/gemini.go @@ -123,6 +123,17 @@ func (g *GeminiCLIAgent) GetSessionDir(repoPath string) (string, error) { return filepath.Join(homeDir, ".gemini", "tmp", projectDir, "chats"), nil } +// GetSessionBaseDir returns the base directory containing per-project session subdirectories. +// Unlike GetSessionDir, this does NOT use ENTIRE_TEST_GEMINI_PROJECT_DIR because the +// test override points to a specific project dir, not the base containing all projects. +func (g *GeminiCLIAgent) GetSessionBaseDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".gemini", "tmp"), nil +} + // ReadSession reads a session from Gemini's storage (JSON transcript file). // The session data is stored in NativeData as raw JSON bytes. func (g *GeminiCLIAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { diff --git a/cmd/entire/cli/agent/geminicli/transcript.go b/cmd/entire/cli/agent/geminicli/transcript.go index 33b611d8d..2ae72d723 100644 --- a/cmd/entire/cli/agent/geminicli/transcript.go +++ b/cmd/entire/cli/agent/geminicli/transcript.go @@ -213,6 +213,94 @@ func GetLastMessageIDFromFile(path string) (string, error) { return GetLastMessageID(data) } +// NormalizeTranscript normalizes user message content fields in-place from +// [{"text":"..."}] arrays to plain strings, preserving all other transcript fields +// (timestamps, thoughts, tokens, model, toolCalls, etc.). +// +// This operates on raw JSON rather than using ParseTranscript + re-marshal because +// GeminiMessage only captures a subset of fields (id, type, content, toolCalls). +// Round-tripping through the struct would silently drop fields like timestamp, model, +// and tokens that are present in real Gemini transcripts. The raw approach rewrites +// only the content values while leaving all other fields untouched. +func NormalizeTranscript(data []byte) ([]byte, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + messagesRaw, ok := raw["messages"] + if !ok { + return data, nil + } + + var messages []json.RawMessage + if err := json.Unmarshal(messagesRaw, &messages); err != nil { + return nil, fmt.Errorf("failed to parse messages: %w", err) + } + + changed := false + for i, msgRaw := range messages { + var msg map[string]json.RawMessage + if err := json.Unmarshal(msgRaw, &msg); err != nil { + continue + } + + contentRaw, hasContent := msg["content"] + if !hasContent || len(contentRaw) == 0 { + continue + } + + // Skip if already a string + var strContent string + if json.Unmarshal(contentRaw, &strContent) == nil { + continue + } + + // Try to convert array of {"text":"..."} to a plain string + var parts []struct { + Text string `json:"text"` + } + if json.Unmarshal(contentRaw, &parts) != nil { + continue + } + + var texts []string + for _, p := range parts { + if p.Text != "" { + texts = append(texts, p.Text) + } + } + joined := strings.Join(texts, "\n") + strBytes, err := json.Marshal(joined) + if err != nil { + continue + } + msg["content"] = strBytes + rewritten, err := json.Marshal(msg) + if err != nil { + continue + } + messages[i] = rewritten + changed = true + } + + if !changed { + return data, nil + } + + rewrittenMessages, err := json.Marshal(messages) + if err != nil { + return nil, fmt.Errorf("failed to re-serialize messages: %w", err) + } + raw["messages"] = rewrittenMessages + + result, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to re-serialize transcript: %w", err) + } + return result, nil +} + // SliceFromMessage returns a Gemini transcript scoped to messages starting from // startMessageIndex. This is the Gemini equivalent of transcript.SliceFromLine — // for Gemini's single JSON blob, scoping is done by message index rather than line offset. diff --git a/cmd/entire/cli/agent/geminicli/transcript_test.go b/cmd/entire/cli/agent/geminicli/transcript_test.go index a375c8f9b..43b304c3f 100644 --- a/cmd/entire/cli/agent/geminicli/transcript_test.go +++ b/cmd/entire/cli/agent/geminicli/transcript_test.go @@ -1,10 +1,56 @@ package geminicli import ( + "encoding/json" "os" "testing" ) +func TestNormalizeTranscript(t *testing.T) { + t.Parallel() + + // Raw Gemini format has content as array of objects for user messages, + // plus extra fields (timestamp, model, tokens) that must be preserved. + raw := []byte(`{"sessionId":"abc","messages":[{"id":"m1","type":"user","timestamp":"2026-01-01T10:00:00Z","content":[{"text":"fix the bug"}]},{"id":"m2","type":"gemini","content":"ok","model":"gemini-3-flash","tokens":{"input":100}}]}`) + normalized, err := NormalizeTranscript(raw) + if err != nil { + t.Fatalf("NormalizeTranscript() error: %v", err) + } + + // After normalization, content should be a plain string + var result struct { + SessionID string `json:"sessionId"` + Messages []struct { + ID string `json:"id"` + Type string `json:"type"` + Content string `json:"content"` + Timestamp string `json:"timestamp"` + Model string `json:"model"` + } `json:"messages"` + } + if err := json.Unmarshal(normalized, &result); err != nil { + t.Fatalf("failed to parse normalized transcript: %v", err) + } + if result.SessionID != "abc" { + t.Errorf("sessionId = %q, want %q", result.SessionID, "abc") + } + if len(result.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(result.Messages)) + } + if result.Messages[0].Content != "fix the bug" { + t.Errorf("user content = %q, want %q", result.Messages[0].Content, "fix the bug") + } + if result.Messages[0].Timestamp != "2026-01-01T10:00:00Z" { + t.Errorf("user timestamp = %q, want preserved", result.Messages[0].Timestamp) + } + if result.Messages[1].Content != "ok" { + t.Errorf("gemini content = %q, want %q", result.Messages[1].Content, "ok") + } + if result.Messages[1].Model != "gemini-3-flash" { + t.Errorf("model = %q, want preserved", result.Messages[1].Model) + } +} + func TestParseTranscript(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go new file mode 100644 index 000000000..fd5c0ad75 --- /dev/null +++ b/cmd/entire/cli/attach.go @@ -0,0 +1,412 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/trailers" + "github.com/entireio/cli/cmd/entire/cli/validation" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" + + "github.com/charmbracelet/huh" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/spf13/cobra" +) + +func newAttachCmd() *cobra.Command { + var ( + force bool + agentFlag string + ) + cmd := &cobra.Command{ + Use: "attach ", + Short: "Attach an existing agent session", + Long: `Attach an existing agent session that wasn't captured by hooks. + +This creates a checkpoint from the session's transcript and links it to the +last commit. Use this when hooks failed to fire or weren't installed when +the session started, or to attach a research session. + +If the last commit already has a checkpoint, the session is added to it. +Otherwise a new checkpoint is created. + +Supported agents: claude-code, gemini, opencode, cursor, copilot-cli, factoryai-droid`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return cmd.Help() + } + if checkDisabledGuard(cmd.Context(), cmd.OutOrStdout()) { + return nil + } + agentName := types.AgentName(agentFlag) + return runAttach(cmd.Context(), cmd.OutOrStdout(), args[0], agentName, force) + }, + } + cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation and amend the last commit with the checkpoint trailer") + cmd.Flags().StringVarP(&agentFlag, "agent", "a", string(agent.DefaultAgentName), "Agent that created the session (claude-code, gemini, opencode, cursor, copilot-cli, factoryai-droid)") + return cmd +} + +func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, force bool) error { + // Initialize structured logger so logging.Warn/Info write to .entire/logs/ not stderr. + if err := logging.Init(ctx, sessionID); err != nil { + // Init failed — logging will use stderr fallback, non-fatal. + _ = err + } + + logCtx := logging.WithComponent(ctx, "attach") + + // Open repository once — shared across all operations. + repo, err := openRepository(ctx) + if err != nil { + return err + } + + existingState, err := validateAttachPreconditions(ctx, repo, sessionID) + if err != nil { + return err + } + + headCommit, err := getHeadCommit(repo) + if err != nil { + return err + } + + // If session already has a checkpoint, just offer to link it. + if existingState != nil && !existingState.LastCheckpointID.IsEmpty() { + cpID := existingState.LastCheckpointID.String() + fmt.Fprintf(w, "Session %s already has checkpoint %s\n", sessionID, cpID) + if err := promptAmendCommit(logCtx, w, headCommit, cpID, force); err != nil { + logging.Warn(logCtx, "failed to amend commit", "error", err) + fmt.Fprintf(w, "\nCopy to your commit message to attach:\n\n Entire-Checkpoint: %s\n", cpID) + } + return nil + } + + // Resolve agent and transcript path. + ag, transcriptPath, err := resolveAgentAndTranscript(logCtx, w, sessionID, agentName, existingState) + if err != nil { + return err + } + + transcriptData, err := ag.ReadTranscript(transcriptPath) + if err != nil { + return fmt.Errorf("failed to read transcript: %w", err) + } + + // Normalize Gemini transcripts for storage. + storedTranscript := transcriptData + if ag.Type() == agent.AgentTypeGemini { + if normalized, normErr := geminicli.NormalizeTranscript(transcriptData); normErr == nil { + storedTranscript = normalized + } else { + logging.Warn(logCtx, "failed to normalize Gemini transcript, storing raw", "error", normErr) + } + } + + meta := extractTranscriptMetadata(transcriptData) + + // Determine checkpoint ID: reuse from HEAD if one exists, otherwise generate new. + checkpointID, isExistingCheckpoint := resolveCheckpointID(headCommit) + + // Write directly to entire/checkpoints/v1. + store := cpkg.NewGitStore(repo) + + author, err := GetGitAuthor(ctx) + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + var prompts []string + if meta.FirstPrompt != "" { + prompts = []string{meta.FirstPrompt} + } + + tokenUsage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, "") + + if err := store.WriteCommitted(ctx, cpkg.WriteCommittedOptions{ + CheckpointID: checkpointID, + SessionID: sessionID, + Strategy: strategy.StrategyNameManualCommit, + Transcript: storedTranscript, + Prompts: prompts, + AuthorName: author.Name, + AuthorEmail: author.Email, + Agent: ag.Type(), + Model: meta.Model, + TokenUsage: tokenUsage, + }); err != nil { + return fmt.Errorf("failed to write checkpoint: %w", err) + } + + // Create or update session state. + if err := saveAttachSessionState(logCtx, existingState, sessionID, ag.Type(), transcriptPath, checkpointID, meta, tokenUsage); err != nil { + logging.Warn(logCtx, "failed to save session state", "error", err) + } + + fmt.Fprintf(w, "Attached session %s\n", sessionID) + if isExistingCheckpoint { + fmt.Fprintf(w, " Added to existing checkpoint %s\n", checkpointID) + return nil + } + + fmt.Fprintf(w, " Created checkpoint %s\n", checkpointID) + cpIDStr := checkpointID.String() + if err := promptAmendCommit(logCtx, w, headCommit, cpIDStr, force); err != nil { + logging.Warn(logCtx, "failed to amend commit", "error", err) + fmt.Fprintf(w, "\nCopy to your commit message to attach:\n\n Entire-Checkpoint: %s\n", cpIDStr) + } + + return nil +} + +// getHeadCommit returns the HEAD commit object. +func getHeadCommit(repo *git.Repository) (*object.Commit, error) { + headRef, err := repo.Head() + if err != nil { + return nil, fmt.Errorf("failed to get HEAD: %w", err) + } + commit, err := repo.CommitObject(headRef.Hash()) + if err != nil { + return nil, fmt.Errorf("failed to get HEAD commit: %w", err) + } + return commit, nil +} + +// resolveCheckpointID returns the checkpoint ID to use for the attach. +// If HEAD already has an Entire-Checkpoint trailer, reuses that ID (the session +// gets added as an additional session in the existing checkpoint). +// Otherwise generates a new ID. +func resolveCheckpointID(headCommit *object.Commit) (id.CheckpointID, bool) { + existing := trailers.ParseAllCheckpoints(headCommit.Message) + if len(existing) > 0 { + return existing[len(existing)-1], true + } + + cpID, err := id.Generate() + if err != nil { + // Generation only fails if crypto/rand fails — extremely unlikely. + // Fall back to empty which will cause WriteCommitted to fail with a clear error. + return id.EmptyCheckpointID, false + } + return cpID, false +} + +// saveAttachSessionState creates or updates the session state file for the attached session. +// If existingState is non-nil, it is updated in place (avoids a redundant disk load). +func saveAttachSessionState(ctx context.Context, existingState *session.State, sessionID string, agentType types.AgentType, transcriptPath string, checkpointID id.CheckpointID, meta transcriptMetadata, tokenUsage *agent.TokenUsage) error { + stateStore, err := session.NewStateStore(ctx) + if err != nil { + return fmt.Errorf("failed to open session store: %w", err) + } + + now := time.Now() + state := existingState + if state == nil { + state = &session.State{ + SessionID: sessionID, + StartedAt: now, + } + } + + state.CLIVersion = versioninfo.Version + state.AttachedManually = true + state.AgentType = agentType + state.TranscriptPath = transcriptPath + state.LastCheckpointID = checkpointID + state.Phase = session.PhaseEnded + state.LastInteractionTime = &now + if meta.TurnCount > 0 { + state.SessionTurnCount = meta.TurnCount + } + if meta.Model != "" { + state.ModelName = meta.Model + } + if meta.FirstPrompt != "" { + state.LastPrompt = meta.FirstPrompt + } + if tokenUsage != nil { + state.TokenUsage = tokenUsage + } + + if err := stateStore.Save(ctx, state); err != nil { + return fmt.Errorf("failed to save session state: %w", err) + } + return nil +} + +// validateAttachPreconditions checks session ID format and git repo state. +// Returns the existing session state if the session is already tracked (nil if new). +func validateAttachPreconditions(ctx context.Context, repo *git.Repository, sessionID string) (*session.State, error) { + if err := validation.ValidateSessionID(sessionID); err != nil { + return nil, fmt.Errorf("invalid session ID: %w", err) + } + + if strategy.IsEmptyRepository(repo) { + return nil, errors.New("repository has no commits yet — make an initial commit before running attach") + } + + store, err := session.NewStateStore(ctx) + if err != nil { + return nil, fmt.Errorf("failed to open session store: %w", err) + } + existing, err := store.Load(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to check existing session: %w", err) + } + + return existing, nil +} + +// resolveAgentAndTranscript resolves the agent and transcript path. +// For existing sessions, resolves the agent from session state's AgentType. +// For new sessions, uses the --agent flag with auto-detection fallback. +func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, existingState *session.State) (agent.Agent, string, error) { + ag, err := resolveAgent(existingState, agentName) + if err != nil { + return nil, "", err + } + + transcriptPath, err := resolveAndValidateTranscript(ctx, sessionID, ag) + if err != nil { + // Auto-detect: try all other agents. + detectedAg, detectedPath, detectErr := detectAgentByTranscript(ctx, sessionID, agentName) + if detectErr != nil { + return nil, "", fmt.Errorf("%w (also tried auto-detecting other agents: %w)", err, detectErr) + } + ag = detectedAg + transcriptPath = detectedPath + logging.Info(ctx, "auto-detected agent from transcript", "agent", ag.Name()) + fmt.Fprintf(w, "Auto-detected agent: %s\n", ag.Name()) + } + + return ag, transcriptPath, nil +} + +// resolveAgent resolves the agent to use. For existing sessions with an AgentType, +// uses agent.GetByAgentType. Otherwise falls back to the --agent flag. +func resolveAgent(existingState *session.State, agentName types.AgentName) (agent.Agent, error) { + if existingState != nil && existingState.AgentType != "" { + ag, err := agent.GetByAgentType(existingState.AgentType) + if err == nil { + return ag, nil + } + // Fall through to flag-based resolution. + } + ag, err := agent.Get(agentName) + if err != nil { + return nil, fmt.Errorf("agent %q not available: %w", agentName, err) + } + return ag, nil +} + +// resolveAndValidateTranscript finds the transcript file for a session, searching alternative +// project directories if needed. +func resolveAndValidateTranscript(ctx context.Context, sessionID string, ag agent.Agent) (string, error) { + transcriptPath, err := resolveTranscriptPath(ctx, sessionID, ag) + if err != nil { + return "", fmt.Errorf("failed to resolve transcript path: %w", err) + } + // Some agents write transcripts asynchronously. PrepareTranscript ensures the + // file is fully flushed before we read it. For finished sessions the file is + // typically stale (>2 min old) and the call returns immediately. + if preparer, ok := agent.AsTranscriptPreparer(ag); ok { + if prepErr := preparer.PrepareTranscript(ctx, transcriptPath); prepErr != nil { + logging.Debug(ctx, "PrepareTranscript failed (best-effort)", "error", prepErr) + } + } + if _, statErr := os.Stat(transcriptPath); statErr == nil { + return transcriptPath, nil + } + found, searchErr := searchTranscriptInProjectDirs(sessionID, ag) + if searchErr == nil { + logging.Info(ctx, "found transcript in alternative project directory", "path", found) + return found, nil + } + logging.Debug(ctx, "fallback transcript search failed", "error", searchErr) + return "", fmt.Errorf("transcript not found for agent %q with session %s; is the session ID correct?", ag.Name(), sessionID) +} + +// detectAgentByTranscript tries all registered agents (except skip) to find one whose +// transcript resolution succeeds for the given session ID. +func detectAgentByTranscript(ctx context.Context, sessionID string, skip types.AgentName) (agent.Agent, string, error) { + for _, name := range agent.List() { + if name == skip { + continue + } + ag, err := agent.Get(name) + if err != nil { + continue + } + path, resolveErr := resolveAndValidateTranscript(ctx, sessionID, ag) + if resolveErr != nil { + logging.Debug(ctx, "auto-detect: agent did not match", "agent", string(name), "error", resolveErr) + continue + } + return ag, path, nil + } + return nil, "", errors.New("transcript not found for any registered agent") +} + +// promptAmendCommit shows the last commit and asks whether to amend it with the checkpoint trailer. +// When force is true, it amends without prompting. +func promptAmendCommit(ctx context.Context, w io.Writer, headCommit *object.Commit, checkpointIDStr string, force bool) error { + shortHash := headCommit.Hash.String()[:7] + subject := strings.SplitN(headCommit.Message, "\n", 2)[0] + + // Skip amending if this exact checkpoint ID is already in the commit. + for _, existing := range trailers.ParseAllCheckpoints(headCommit.Message) { + if existing.String() == checkpointIDStr { + fmt.Fprintf(w, "Commit %s already has Entire-Checkpoint: %s\n", shortHash, checkpointIDStr) + return nil + } + } + + fmt.Fprintf(w, "\nLast commit: %s %s\n", shortHash, subject) + + amend := true + if !force { + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Amend the last commit in this branch?"). + Affirmative("Y"). + Negative("n"). + Value(&amend), + ), + ) + if err := form.Run(); err != nil { + return fmt.Errorf("prompt failed: %w", err) + } + } + + if !amend { + fmt.Fprintf(w, "\nCopy to your commit message to attach:\n\n Entire-Checkpoint: %s\n", checkpointIDStr) + return nil + } + + newMessage := trailers.AppendCheckpointTrailer(headCommit.Message, checkpointIDStr) + + cmd := exec.CommandContext(ctx, "git", "commit", "--amend", "--only", "-m", newMessage) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to amend commit: %w\n%s", err, output) + } + + fmt.Fprintf(w, "Amended commit %s with Entire-Checkpoint: %s\n", shortHash, checkpointIDStr) + return nil +} diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go new file mode 100644 index 000000000..a8fe6e19d --- /dev/null +++ b/cmd/entire/cli/attach_test.go @@ -0,0 +1,577 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // register agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" // register agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" // register agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" // register agent + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +func TestAttach_MissingSessionID(t *testing.T) { + t.Parallel() + + cmd := newAttachCmd() + cmd.SetArgs([]string{}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + err := cmd.Execute() + if err != nil { + t.Fatalf("expected help output, got error: %v", err) + } + if !strings.Contains(out.String(), "attach ") { + t.Errorf("expected help output containing usage, got: %s", out.String()) + } +} + +func TestAttach_TranscriptNotFound(t *testing.T) { + setupAttachTestRepo(t) + + // Set up a fake Claude project dir that's empty + t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", t.TempDir()) + // Redirect HOME so the fallback search doesn't walk real ~/.claude/projects + t.Setenv("HOME", t.TempDir()) + + var out bytes.Buffer + err := runAttach(context.Background(), &out, "nonexistent-session-id", agent.AgentNameClaudeCode, false) + if err == nil { + t.Fatal("expected error for missing transcript") + } +} + +func TestAttach_Success(t *testing.T) { + setupAttachTestRepo(t) + + sessionID := "test-attach-session-001" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"create a hello world file"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"Write","input":{"file_path":"hello.txt","content":"world"}}]},"uuid":"uuid-2"} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu_1","content":"wrote file"}]},"uuid":"uuid-3"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done! I created hello.txt."}]},"uuid":"uuid-4"} +`) + + var out bytes.Buffer + err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + // Verify session state was created + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + state, err := store.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state == nil { + t.Fatal("expected session state to be created") + } + if state.SessionID != sessionID { + t.Errorf("session ID = %q, want %q", state.SessionID, sessionID) + } + if state.LastCheckpointID.IsEmpty() { + t.Error("expected LastCheckpointID to be set after attach") + } + + // Verify output message + output := out.String() + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", output) + } + if !strings.Contains(output, "Created checkpoint") { + t.Errorf("expected 'Created checkpoint' in output, got: %s", output) + } +} + +func TestAttach_SessionAlreadyTracked_NoCheckpoint(t *testing.T) { + setupAttachTestRepo(t) + + sessionID := "test-attach-duplicate" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"explain the auth module"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The auth module handles user authentication via JWT tokens."}]},"uuid":"uuid-2"} +`) + + // Pre-create session state without a checkpoint ID (simulates hooks tracking + // the session but condensation never happening). + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := store.Save(context.Background(), &session.State{ + SessionID: sessionID, + AgentType: agent.AgentTypeClaudeCode, + StartedAt: time.Now(), + }); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err = runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false) + if err != nil { + t.Fatalf("expected attach to handle already-tracked session, got error: %v", err) + } + output := out.String() + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", output) + } + + // Verify checkpoint was created + reloadedState, err := store.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if reloadedState.LastCheckpointID.IsEmpty() { + t.Error("expected LastCheckpointID to be set after re-attach") + } +} + +func TestAttach_OutputContainsCheckpointID(t *testing.T) { + setupAttachTestRepo(t) + + sessionID := "test-attach-checkpoint-output" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"add error handling"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"main.go","old_string":"return nil","new_string":"return fmt.Errorf(\"failed: %w\", err)"}}]},"uuid":"uuid-2"} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu_1","content":"edited file"}]},"uuid":"uuid-3"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Added error wrapping."}]},"uuid":"uuid-4"} +`) + + var out bytes.Buffer + err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + output := out.String() + + // Must contain Entire-Checkpoint trailer with 12-hex-char ID + re := regexp.MustCompile(`Entire-Checkpoint: [0-9a-f]{12}`) + if !re.MatchString(output) { + t.Errorf("expected 'Entire-Checkpoint: <12-hex-id>' in output, got:\n%s", output) + } +} + +func TestAttach_PopulatesTokenUsage(t *testing.T) { + setupAttachTestRepo(t) + + sessionID := "test-attach-token-usage" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"} +{"type":"assistant","message":{"id":"msg_1","role":"assistant","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"uuid":"a1"} +`) + + var out bytes.Buffer + if err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false); err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + state, err := store.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.TokenUsage == nil { + t.Fatal("expected TokenUsage to be set") + } + if state.TokenUsage.OutputTokens == 0 { + t.Error("expected OutputTokens > 0") + } +} + +func TestAttach_SetsSessionTurnCount(t *testing.T) { + setupAttachTestRepo(t) + + sessionID := "test-attach-turn-count" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"first prompt"},"uuid":"u1"} +{"type":"assistant","message":{"role":"assistant","content":"response 1"},"uuid":"a1"} +{"type":"user","message":{"role":"user","content":"second prompt"},"uuid":"u2"} +{"type":"assistant","message":{"role":"assistant","content":"response 2"},"uuid":"a2"} +`) + + var out bytes.Buffer + if err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false); err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + state, err := store.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.SessionTurnCount != 2 { + t.Errorf("SessionTurnCount = %d, want 2", state.SessionTurnCount) + } +} + +func TestCountUserTurns(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data []byte + want int + }{ + { + name: "gemini format", + data: []byte(`{"messages":[{"type":"user","content":"first"},{"type":"gemini","content":"ok"},{"type":"user","content":"second"},{"type":"gemini","content":"done"}]}`), + want: 2, + }, + { + name: "jsonl with tool_result should not double count", + data: []byte(`{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"Write","input":{}}]},"uuid":"a1"} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu_1","content":"ok"}]},"uuid":"u2"} +{"type":"user","message":{"role":"user","content":"next prompt"},"uuid":"u3"} +`), + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractTranscriptMetadata(tt.data).TurnCount + if got != tt.want { + t.Errorf("extractTranscriptMetadata().TurnCount = %d, want %d", got, tt.want) + } + }) + } +} + +func TestExtractModelFromTranscript(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data []byte + want string + }{ + { + name: "claude code with model", + data: []byte(`{"type":"user","message":{"role":"user","content":"hi"},"uuid":"u1"} +{"type":"assistant","message":{"id":"msg_1","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"hello"}]},"uuid":"a1"} +`), + want: "claude-sonnet-4-20250514", + }, + { + name: "no model field", + data: []byte(`{"type":"user","message":{"role":"user","content":"hi"},"uuid":"u1"} +{"type":"assistant","message":{"role":"assistant","content":"hello"},"uuid":"a1"} +`), + want: "", + }, + { + name: "gemini format (no model in transcript)", + data: []byte(`{"messages":[{"type":"user","content":"hi"},{"type":"gemini","content":"hello"}]}`), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractTranscriptMetadata(tt.data).Model + if got != tt.want { + t.Errorf("extractTranscriptMetadata().Model = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractFirstPromptFromTranscript_GeminiFormat(t *testing.T) { + t.Parallel() + + data := []byte(`{"messages":[{"type":"user","content":"fix the login bug"},{"type":"gemini","content":"I'll look at that"}]}`) + got := extractTranscriptMetadata(data).FirstPrompt + if got != "fix the login bug" { + t.Errorf("extractTranscriptMetadata(gemini).FirstPrompt = %q, want %q", got, "fix the login bug") + } +} + +func TestExtractFirstPromptFromTranscript_JSONLFormat(t *testing.T) { + t.Parallel() + + data := []byte(`{"type":"user","message":{"role":"user","content":"hello world"},"uuid":"u1"} +{"type":"assistant","message":{"role":"assistant","content":"hi"},"uuid":"a1"} +`) + got := extractTranscriptMetadata(data).FirstPrompt + if got != "hello world" { + t.Errorf("extractTranscriptMetadata(jsonl).FirstPrompt = %q, want %q", got, "hello world") + } +} + +func TestAttach_GeminiSubdirectorySession(t *testing.T) { + setupAttachTestRepo(t) + + // Redirect HOME so searchTranscriptInProjectDirs searches our fake Gemini dir + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + + // Create a Gemini transcript in a *different* project hash directory, + // simulating a session started from a subdirectory (different CWD hash). + differentProjectDir := filepath.Join(fakeHome, ".gemini", "tmp", "different-hash", "chats") + if err := os.MkdirAll(differentProjectDir, 0o750); err != nil { + t.Fatal(err) + } + + sessionID := "abcd1234-gemini-subdir-test" + transcriptContent := `{"messages":[{"type":"user","content":"hello"},{"type":"gemini","content":"hi"}]}` + // Gemini names files as session--.json where shortid = sessionID[:8] + transcriptFile := filepath.Join(differentProjectDir, "session-2026-01-01T10-00-abcd1234.json") + if err := os.WriteFile(transcriptFile, []byte(transcriptContent), 0o600); err != nil { + t.Fatal(err) + } + + // Set the expected project dir to an empty directory so the primary lookup fails + // and the fallback search kicks in. + emptyProjectDir := t.TempDir() + t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", emptyProjectDir) + + var out bytes.Buffer + err := runAttach(context.Background(), &out, sessionID, agent.AgentNameGemini, false) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + output := out.String() + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", output) + } + + store, storeErr := session.NewStateStore(context.Background()) + if storeErr != nil { + t.Fatal(storeErr) + } + state, loadErr := store.Load(context.Background(), sessionID) + if loadErr != nil { + t.Fatal(loadErr) + } + if state == nil { + t.Fatal("expected session state to be created") + } + if state.AgentType != agent.AgentTypeGemini { + t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini) + } + if state.LastCheckpointID.IsEmpty() { + t.Error("expected LastCheckpointID to be set after attach") + } +} + +func TestAttach_GeminiSuccess(t *testing.T) { + setupAttachTestRepo(t) + + // Create Gemini transcript in expected project dir + geminiDir := t.TempDir() + t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", geminiDir) + + sessionID := "abcd1234-gemini-success-test" + transcriptContent := `{"messages":[{"type":"user","content":"fix the login bug"},{"type":"gemini","content":"I will fix the login bug now."}]}` + transcriptFile := filepath.Join(geminiDir, "session-2026-01-01T10-00-abcd1234.json") + if err := os.WriteFile(transcriptFile, []byte(transcriptContent), 0o600); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := runAttach(context.Background(), &out, sessionID, agent.AgentNameGemini, false) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + output := out.String() + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", output) + } + + // Verify session state + store, storeErr := session.NewStateStore(context.Background()) + if storeErr != nil { + t.Fatal(storeErr) + } + state, loadErr := store.Load(context.Background(), sessionID) + if loadErr != nil { + t.Fatal(loadErr) + } + if state == nil { + t.Fatal("expected session state to be created") + } + if state.AgentType != agent.AgentTypeGemini { + t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini) + } + if state.SessionTurnCount != 1 { + t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount) + } +} + +func TestAttach_CursorSuccess(t *testing.T) { + setupAttachTestRepo(t) + + cursorDir := t.TempDir() + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", cursorDir) + + sessionID := "test-attach-cursor-session" + // Cursor uses JSONL format, same as Claude Code + transcriptContent := `{"type":"user","message":{"role":"user","content":"add dark mode"},"uuid":"u1"} +{"type":"assistant","message":{"role":"assistant","content":"I'll add dark mode support."},"uuid":"a1"} +` + // Cursor flat layout: /.jsonl + if err := os.WriteFile(filepath.Join(cursorDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, false) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + if !strings.Contains(out.String(), "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", out.String()) + } + + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + state, err := store.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state == nil { + t.Fatal("expected session state to be created") + } + if state.AgentType != agent.AgentTypeCursor { + t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCursor) + } + if state.SessionTurnCount != 1 { + t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount) + } +} + +func TestAttach_FactoryAIDroidSuccess(t *testing.T) { + setupAttachTestRepo(t) + + droidDir := t.TempDir() + t.Setenv("ENTIRE_TEST_DROID_PROJECT_DIR", droidDir) + + sessionID := "test-attach-droid-session" + // Factory AI Droid uses JSONL format + transcriptContent := `{"type":"user","message":{"role":"user","content":"deploy to staging"},"uuid":"u1"} +{"type":"assistant","message":{"role":"assistant","content":"Deploying to staging now."},"uuid":"a1"} +` + // Factory AI Droid: flat /.jsonl + if err := os.WriteFile(filepath.Join(droidDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := runAttach(context.Background(), &out, sessionID, agent.AgentNameFactoryAIDroid, false) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + if !strings.Contains(out.String(), "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", out.String()) + } + + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + state, err := store.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state == nil { + t.Fatal("expected session state to be created") + } + if state.AgentType != agent.AgentTypeFactoryAIDroid { + t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeFactoryAIDroid) + } + if state.SessionTurnCount != 1 { + t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount) + } +} + +func TestAttach_CursorNestedLayout(t *testing.T) { + setupAttachTestRepo(t) + + cursorDir := t.TempDir() + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", cursorDir) + + sessionID := "test-cursor-nested-layout" + transcriptContent := `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"} +` + // Cursor IDE nested layout: //.jsonl + nestedDir := filepath.Join(cursorDir, sessionID) + if err := os.MkdirAll(nestedDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, false) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + if !strings.Contains(out.String(), "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", out.String()) + } +} + +// setupAttachTestRepo creates a temp git repo with one commit and enables Entire. +// Returns the repo directory. Caller must not use t.Parallel() (uses t.Chdir). +func setupAttachTestRepo(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + enableEntire(t, tmpDir) +} + +// setupClaudeTranscript creates a fake Claude transcript file. +func setupClaudeTranscript(t *testing.T, sessionID, content string) { + t.Helper() + claudeDir := t.TempDir() + t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", claudeDir) + if err := os.WriteFile(filepath.Join(claudeDir, sessionID+".jsonl"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// enableEntire creates the .entire/settings.json file to mark Entire as enabled. +func enableEntire(t *testing.T, repoDir string) { + t.Helper() + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o750); err != nil { + t.Fatal(err) + } + settingsContent := `{"enabled": true}` + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(settingsContent), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/entire/cli/attach_transcript.go b/cmd/entire/cli/attach_transcript.go new file mode 100644 index 000000000..ea7d16859 --- /dev/null +++ b/cmd/entire/cli/attach_transcript.go @@ -0,0 +1,56 @@ +package cli + +import ( + "encoding/json" + + "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// transcriptMetadata holds metadata extracted from a single transcript parse pass. +type transcriptMetadata struct { + FirstPrompt string + TurnCount int + Model string +} + +// extractTranscriptMetadata parses transcript bytes once and extracts the first user prompt, +// user turn count, and model name. Supports both JSONL (Claude Code, Cursor, OpenCode) and +// Gemini JSON format. +func extractTranscriptMetadata(data []byte) transcriptMetadata { + var meta transcriptMetadata + + // Try JSONL format first (Claude Code, Cursor, OpenCode, etc.) + lines, err := transcript.ParseFromBytes(data) + if err == nil { + for _, line := range lines { + if line.Type == transcript.TypeUser { + if prompt := transcript.ExtractUserContent(line.Message); prompt != "" { + meta.TurnCount++ + if meta.FirstPrompt == "" { + meta.FirstPrompt = prompt + } + } + } + if line.Type == transcript.TypeAssistant && meta.Model == "" { + var msg struct { + Model string `json:"model"` + } + if json.Unmarshal(line.Message, &msg) == nil && msg.Model != "" { + meta.Model = msg.Model + } + } + } + if meta.TurnCount > 0 || meta.Model != "" { + return meta + } + } + + // Fallback: try Gemini JSON format {"messages": [...]} + if prompts, gemErr := geminicli.ExtractAllUserPrompts(data); gemErr == nil && len(prompts) > 0 { + meta.FirstPrompt = prompts[0] + meta.TurnCount = len(prompts) + } + + return meta +} diff --git a/cmd/entire/cli/integration_test/attach_test.go b/cmd/entire/cli/integration_test/attach_test.go new file mode 100644 index 000000000..c1579a367 --- /dev/null +++ b/cmd/entire/cli/integration_test/attach_test.go @@ -0,0 +1,318 @@ +//go:build integration + +package integration + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/trailers" +) + +// TestAttach_NewSession_NoHooks tests attaching a session that was never tracked by hooks. +// Scenario: agent ran outside of Entire's hooks, user wants to import the session. +func TestAttach_NewSession_NoHooks(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + // Simulate an agent session that happened without hooks: + // create a transcript file in the Claude project dir. + sessionID := "attach-new-session-001" + tb := NewTranscriptBuilder() + tb.AddUserMessage("explain the auth module") + tb.AddAssistantMessage("The auth module handles user authentication.") + transcriptPath := filepath.Join(env.ClaudeProjectDir, sessionID+".jsonl") + if err := tb.WriteToFile(transcriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Run attach + output := env.RunCLI("attach", sessionID, "-a", "claude-code", "-f") + + // Verify output + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got:\n%s", output) + } + if !strings.Contains(output, "Created checkpoint") { + t.Errorf("expected 'Created checkpoint' in output, got:\n%s", output) + } + if !strings.Contains(output, "Entire-Checkpoint") { + t.Errorf("expected checkpoint trailer in output, got:\n%s", output) + } + + // Verify the commit was amended with the checkpoint trailer + headMsg := env.GetCommitMessage(env.GetHeadHash()) + cpID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if cpID == "" { + t.Errorf("expected Entire-Checkpoint trailer on HEAD, commit message:\n%s", headMsg) + } + + // Verify session state was created + sessionStateFile := filepath.Join(env.RepoDir, ".git", "entire-sessions", sessionID+".json") + if _, err := os.Stat(sessionStateFile); err != nil { + t.Errorf("expected session state file at %s: %v", sessionStateFile, err) + } +} + +// TestAttach_ResearchSession_NoFileChanges tests attaching a research/exploration session +// that didn't modify any files — transcript only. +func TestAttach_ResearchSession_NoFileChanges(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + // Create a research session — only questions and answers, no tool use. + sessionID := "attach-research-session" + tb := NewTranscriptBuilder() + tb.AddUserMessage("how does the rate limiter work?") + tb.AddAssistantMessage("The rate limiter uses a token bucket algorithm...") + tb.AddUserMessage("what about the retry logic?") + tb.AddAssistantMessage("Retries use exponential backoff with jitter...") + transcriptPath := filepath.Join(env.ClaudeProjectDir, sessionID+".jsonl") + if err := tb.WriteToFile(transcriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + output := env.RunCLI("attach", sessionID, "-a", "claude-code", "-f") + + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got:\n%s", output) + } + + // Verify checkpoint was created and linked + cpID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if cpID == "" { + t.Error("expected Entire-Checkpoint trailer on HEAD") + } +} + +// TestAttach_ExistingCheckpoint_AddSession tests attaching a session to a commit +// that already has a checkpoint from a different session. The new session should be +// added to the existing checkpoint (same checkpoint ID, not a second trailer). +func TestAttach_ExistingCheckpoint_AddSession(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + // First: run a normal session through hooks to create a checkpoint. + session1 := env.NewSession() + if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "add login endpoint"); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + env.WriteFile("src/login.go", "package main\n\nfunc Login() {}") + session1.CreateTranscript("add login endpoint", []FileChange{ + {Path: "src/login.go", Content: "package main\n\nfunc Login() {}"}, + }) + + if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Commit with hooks to trigger condensation and get checkpoint trailer. + env.GitCommitWithShadowHooks("add login endpoint", "src/login.go") + + // Verify first checkpoint exists + firstCpID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if firstCpID == "" { + t.Fatal("expected checkpoint on commit after first session") + } + + // Second: create a research session transcript (no hooks, no file changes). + session2ID := "attach-second-session" + tb := NewTranscriptBuilder() + tb.AddUserMessage("explain the login flow") + tb.AddAssistantMessage("The login endpoint validates credentials and issues a JWT.") + transcriptPath := filepath.Join(env.ClaudeProjectDir, session2ID+".jsonl") + if err := tb.WriteToFile(transcriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Attach the second session + output := env.RunCLI("attach", session2ID, "-a", "claude-code") + + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got:\n%s", output) + } + if !strings.Contains(output, "Added to existing checkpoint") { + t.Errorf("expected 'Added to existing checkpoint' in output, got:\n%s", output) + } + + // Verify only one checkpoint trailer on the commit (same ID reused). + headMsg := env.GetCommitMessage(env.GetHeadHash()) + allCpIDs := trailers.ParseAllCheckpoints(headMsg) + if len(allCpIDs) != 1 { + t.Errorf("expected 1 checkpoint trailer, got %d: %v\nCommit message:\n%s", len(allCpIDs), allCpIDs, headMsg) + } + if len(allCpIDs) > 0 && allCpIDs[0].String() != firstCpID { + t.Errorf("checkpoint ID changed: was %s, now %s", firstCpID, allCpIDs[0].String()) + } +} + +// TestAttach_AlreadyTracked_NoCheckpoint tests attaching a session that was tracked +// by hooks (session state exists) but never got a checkpoint (e.g., no file changes +// during the session, so condensation never happened). +func TestAttach_AlreadyTracked_NoCheckpoint(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + // Start a session through hooks (creates session state). + session1 := env.NewSession() + if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "what does this code do?"); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Create a transcript but don't modify any files. + session1.CreateTranscript("what does this code do?", nil) + + // Stop — no file changes, so no checkpoint is created. + if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Write the transcript to the Claude project dir so attach can find it. + claudeTranscriptPath := filepath.Join(env.ClaudeProjectDir, session1.ID+".jsonl") + transcriptData, err := os.ReadFile(session1.TranscriptPath) + if err != nil { + t.Fatalf("failed to read transcript: %v", err) + } + if err := os.WriteFile(claudeTranscriptPath, transcriptData, 0o600); err != nil { + t.Fatalf("failed to copy transcript: %v", err) + } + + // Commit something (unrelated, no hooks) to have a HEAD to amend. + env.WriteFile("notes.txt", "research notes") + env.GitAdd("notes.txt") + env.GitCommit("add research notes") + + // Now attach — session state exists but has no checkpoint. + // Don't use -f because the amend may fail in the test environment + // (pre-commit hooks reference paths that don't exist in the temp repo). + output := env.RunCLI("attach", session1.ID, "-a", "claude-code") + + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got:\n%s", output) + } + if !strings.Contains(output, "Created checkpoint") { + t.Errorf("expected 'Created checkpoint' in output, got:\n%s", output) + } + + // Verify session state was updated with checkpoint ID. + sessionStateFile := filepath.Join(env.RepoDir, ".git", "entire-sessions", session1.ID+".json") + if _, err := os.Stat(sessionStateFile); err != nil { + t.Errorf("expected session state file: %v", err) + } +} + +// TestAttach_AlreadyTracked_HasCheckpoint tests that re-attaching a session that already +// has a checkpoint just offers to link it (no duplicate checkpoint created). +func TestAttach_AlreadyTracked_HasCheckpoint(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + // Run a full session: hooks → file changes → commit → condensation. + session1 := env.NewSession() + if err := env.SimulateUserPromptSubmitWithPrompt(session1.ID, "add config parser"); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + env.WriteFile("config.go", "package config\n\nfunc Parse() {}") + session1.CreateTranscript("add config parser", []FileChange{ + {Path: "config.go", Content: "package config\n\nfunc Parse() {}"}, + }) + + if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Commit with hooks to get checkpoint trailer. + env.GitCommitWithShadowHooks("add config parser", "config.go") + + firstCpID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) + if firstCpID == "" { + t.Fatal("expected checkpoint on commit") + } + + // Write transcript to Claude project dir for attach resolution. + claudeTranscriptPath := filepath.Join(env.ClaudeProjectDir, session1.ID+".jsonl") + transcriptData, err := os.ReadFile(session1.TranscriptPath) + if err != nil { + t.Fatalf("failed to read transcript: %v", err) + } + if err := os.WriteFile(claudeTranscriptPath, transcriptData, 0o600); err != nil { + t.Fatalf("failed to copy transcript: %v", err) + } + + // Re-attach the same session + output := env.RunCLI("attach", session1.ID, "-a", "claude-code") + + if !strings.Contains(output, "already has checkpoint") { + t.Errorf("expected 'already has checkpoint' in output, got:\n%s", output) + } + + // Verify no duplicate trailer was added + headMsg := env.GetCommitMessage(env.GetHeadHash()) + allCpIDs := trailers.ParseAllCheckpoints(headMsg) + if len(allCpIDs) != 1 { + t.Errorf("expected exactly 1 checkpoint trailer, got %d\nCommit message:\n%s", len(allCpIDs), headMsg) + } +} + +// TestAttach_DifferentWorkingDirectory tests attaching a session whose transcript +// lives under a different project directory (e.g., the agent was started from a +// subdirectory). The fallback search in searchTranscriptInProjectDirs should find it. +func TestAttach_DifferentWorkingDirectory(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + // Create a fake HOME with a Claude projects dir containing the transcript + // under a different project hash than what the CLI would compute for env.RepoDir. + fakeHome := t.TempDir() + differentProjectDir := filepath.Join(fakeHome, ".claude", "projects", "different-project-hash") + if err := os.MkdirAll(differentProjectDir, 0o750); err != nil { + t.Fatal(err) + } + + sessionID := "attach-different-dir-session" + tb := NewTranscriptBuilder() + tb.AddUserMessage("what is the project structure?") + tb.AddAssistantMessage("The project has the following structure...") + if err := tb.WriteToFile(filepath.Join(differentProjectDir, sessionID+".jsonl")); err != nil { + t.Fatal(err) + } + + // Set ENTIRE_TEST_CLAUDE_PROJECT_DIR to an empty dir so the primary lookup fails, + // and set HOME to fakeHome so the fallback search finds our transcript. + emptyProjectDir := t.TempDir() + cmd := exec.Command(getTestBinary(), "attach", sessionID, "-a", "claude-code", "-f") + cmd.Dir = env.RepoDir + cmd.Env = append(env.cliEnv(), + "HOME="+fakeHome, + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+emptyProjectDir, + ) + outputBytes, err := cmd.CombinedOutput() + output := string(outputBytes) + t.Logf("attach output:\n%s", output) + if err != nil { + t.Fatalf("attach failed: %v\nOutput: %s", err, output) + } + + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got:\n%s", output) + } + if !strings.Contains(output, "Created checkpoint") { + t.Errorf("expected 'Created checkpoint' in output, got:\n%s", output) + } +} + +// TestAttach_InvalidSessionID tests that an invalid session ID is rejected. +func TestAttach_InvalidSessionID(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + _, err := env.RunCLIWithError("attach", "../path-traversal", "-a", "claude-code") + if err == nil { + t.Error("expected error for invalid session ID") + } +} diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index ea571c78d..67ddc6eb4 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -412,12 +412,22 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev // Generate commit message from last prompt (read from session state, set at TurnStart). // In exec mode, session state LastPrompt may be empty because UserPromptSubmit never fires. // Fall back to backfilledPrompt extracted from the transcript. + // Single load serves both prompt retrieval and backfill. _, commitMsgSpan := perf.Start(ctx, "generate_commit_message") lastPrompt := "" if sessionState, stateErr := strategy.LoadSessionState(ctx, sessionID); stateErr == nil && sessionState != nil { lastPrompt = sessionState.LastPrompt - } - if lastPrompt == "" && backfilledPrompt != "" { + // Backfill LastPrompt so `entire status` shows the prompt even when + // no files were modified (before the early return below). + if lastPrompt == "" && backfilledPrompt != "" { + lastPrompt = backfilledPrompt + sessionState.LastPrompt = backfilledPrompt + if saveErr := strategy.SaveSessionState(ctx, sessionState); saveErr != nil { + logging.Warn(logCtx, "failed to backfill LastPrompt in session state", + slog.String("error", saveErr.Error())) + } + } + } else if backfilledPrompt != "" { lastPrompt = backfilledPrompt } commitMessage := generateCommitMessage(lastPrompt, ag.Type()) @@ -471,18 +481,6 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev relModifiedFiles = filterToUncommittedFiles(ctx, relModifiedFiles, repoRoot) normalizeSpan.End() - // Backfill session state LastPrompt early so `entire status` shows the prompt - // even when no files were modified (before the early return below). - if backfilledPrompt != "" { - if state, stateErr := strategy.LoadSessionState(ctx, sessionID); stateErr == nil && state != nil && state.LastPrompt == "" { - state.LastPrompt = backfilledPrompt - if saveErr := strategy.SaveSessionState(ctx, state); saveErr != nil { - logging.Warn(logCtx, "failed to backfill LastPrompt in session state", - slog.String("error", saveErr.Error())) - } - } - } - // Check if there are any changes totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) if totalChanges == 0 { diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index a171474ca..63b976cf3 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -94,6 +94,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newTrailCmd()) cmd.AddCommand(newSendAnalyticsCmd()) + cmd.AddCommand(newAttachCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) cmd.SetVersionTemplate(versionString()) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 2fb5d4ea2..bd666d952 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -123,6 +123,10 @@ type State struct { // ACTIVE via TurnStart, or ENDED → IDLE via SessionStart) by ActionClearEndedAt. FullyCondensed bool `json:"fully_condensed,omitempty"` + // AttachedManually indicates this session was imported via `entire attach` rather + // than being captured by hooks during normal agent execution. + AttachedManually bool `json:"attached_manually,omitempty"` + // AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor") AgentType types.AgentType `json:"agent_type,omitempty"` diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 7d2f68428..6bebf406b 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1677,62 +1677,9 @@ func (s *ManualCommitStrategy) addTrailerForAgentCommit(logCtx context.Context, } // addCheckpointTrailer adds the Entire-Checkpoint trailer to a commit message. -// Handles proper trailer formatting (blank line before trailers if needed). +// Delegates to trailers.AppendCheckpointTrailer for trailer-aware formatting. func addCheckpointTrailer(message string, checkpointID id.CheckpointID) string { - trailer := trailers.CheckpointTrailerKey + ": " + checkpointID.String() - - // If message already ends with trailers (lines starting with key:), just append - // Otherwise, add a blank line first - lines := strings.Split(strings.TrimRight(message, "\n"), "\n") - - // Check if the message already ends with a trailer paragraph. - // Git trailers must be in a separate paragraph (preceded by a blank line). - // A single-paragraph message (e.g., just a subject line) cannot have trailers, - // even if the subject contains ": " (like conventional commits: "docs: Add foo"). - // - // Scan from the bottom: find the last paragraph of non-comment content, - // then check if it looks like trailers AND has a blank line above it. - hasTrailers := false - i := len(lines) - 1 - - // Skip trailing comment lines - for i >= 0 && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") { - i-- - } - - // Check if the last non-comment line looks like a trailer - if i >= 0 { - line := strings.TrimSpace(lines[i]) - if line != "" && strings.Contains(line, ": ") { - // Found a trailer-like line. Now scan upward past the trailer block - // to verify there's a blank line (paragraph separator) above it. - for i > 0 { - i-- - above := strings.TrimSpace(lines[i]) - if strings.HasPrefix(above, "#") { - continue - } - if above == "" { - // Blank line found above trailer block — real trailers - hasTrailers = true - break - } - if !strings.Contains(above, ": ") { - // Non-trailer, non-blank line — this is message body, not trailers - break - } - // Another trailer-like line, keep scanning upward - } - } - } - - if hasTrailers { - // Append trailer directly - return strings.TrimRight(message, "\n") + "\n" + trailer + "\n" - } - - // Add blank line before trailer - return strings.TrimRight(message, "\n") + "\n\n" + trailer + "\n" + return trailers.AppendCheckpointTrailer(message, checkpointID.String()) } // addCheckpointTrailerWithComment adds the Entire-Checkpoint trailer with an explanatory comment. diff --git a/cmd/entire/cli/trailers/trailers.go b/cmd/entire/cli/trailers/trailers.go index 40060a6c4..cd5561d57 100644 --- a/cmd/entire/cli/trailers/trailers.go +++ b/cmd/entire/cli/trailers/trailers.go @@ -252,3 +252,51 @@ func FormatShadowTaskCommit(message, taskMetadataDir, sessionID string) string { func FormatCheckpoint(message string, cpID checkpointID.CheckpointID) string { return fmt.Sprintf("%s\n\n%s: %s\n", message, CheckpointTrailerKey, cpID.String()) } + +// trailerLineRe matches git trailer format: "Key-Name: value" (no spaces before colon). +var trailerLineRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*: `) + +// IsTrailerLine reports whether a line matches git trailer format. +func IsTrailerLine(line string) bool { + return trailerLineRe.MatchString(line) +} + +// AppendCheckpointTrailer appends Entire-Checkpoint in trailer-aware format. +// If the message already ends with a trailer paragraph, append directly to it; +// otherwise add a blank line before starting a new trailer block. +func AppendCheckpointTrailer(message, checkpointID string) string { + trimmed := strings.TrimRight(message, "\n") + trailer := fmt.Sprintf("%s: %s", CheckpointTrailerKey, checkpointID) + + lines := strings.Split(trimmed, "\n") + i := len(lines) - 1 + for i >= 0 && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") { + i-- + } + + hasTrailerBlock := false + if i >= 0 { + last := strings.TrimSpace(lines[i]) + if last != "" && IsTrailerLine(last) { + for i > 0 { + i-- + above := strings.TrimSpace(lines[i]) + if strings.HasPrefix(above, "#") { + continue + } + if above == "" { + hasTrailerBlock = true + break + } + if !IsTrailerLine(above) { + break + } + } + } + } + + if hasTrailerBlock { + return trimmed + "\n" + trailer + "\n" + } + return trimmed + "\n\n" + trailer + "\n" +} diff --git a/cmd/entire/cli/trailers/trailers_test.go b/cmd/entire/cli/trailers/trailers_test.go index b750dc07b..2fd0e8961 100644 --- a/cmd/entire/cli/trailers/trailers_test.go +++ b/cmd/entire/cli/trailers/trailers_test.go @@ -6,6 +6,76 @@ import ( checkpointID "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" ) +func TestAppendCheckpointTrailer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msg string + want string + }{ + { + name: "no existing trailers", + msg: "feat: add attach command\n", + want: "feat: add attach command\n\nEntire-Checkpoint: abc123def456\n", + }, + { + name: "existing non-checkpoint trailer block", + msg: "feat: add attach command\n\nSigned-off-by: Test User \n", + want: "feat: add attach command\n\nSigned-off-by: Test User \nEntire-Checkpoint: abc123def456\n", + }, + { + name: "existing checkpoint trailer block", + msg: "feat: add attach command\n\nEntire-Checkpoint: deadbeefcafe\n", + want: "feat: add attach command\n\nEntire-Checkpoint: deadbeefcafe\nEntire-Checkpoint: abc123def456\n", + }, + { + name: "subject with colon is not trailer block", + msg: "docs: update readme\n", + want: "docs: update readme\n\nEntire-Checkpoint: abc123def456\n", + }, + { + name: "body text containing colon-space is not trailer block", + msg: "fix: login\n\nThis fixes the error: connection refused\n", + want: "fix: login\n\nThis fixes the error: connection refused\n\nEntire-Checkpoint: abc123def456\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := AppendCheckpointTrailer(tt.msg, "abc123def456") + if got != tt.want { + t.Errorf("AppendCheckpointTrailer() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIsTrailerLine(t *testing.T) { + t.Parallel() + + tests := []struct { + line string + want bool + }{ + {"Signed-off-by: User ", true}, + {"Entire-Checkpoint: abc123def456", true}, + {"not a trailer", false}, + {"error: connection refused", true}, // "error" is a valid trailer key format + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.line, func(t *testing.T) { + t.Parallel() + if got := IsTrailerLine(tt.line); got != tt.want { + t.Errorf("IsTrailerLine(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} + func TestFormatMetadata(t *testing.T) { message := "Update authentication logic" metadataDir := ".entire/metadata/2025-01-28-abc123" diff --git a/cmd/entire/cli/transcript.go b/cmd/entire/cli/transcript.go index 3bb5d9587..bb52cb07a 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -4,21 +4,18 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "os" "path/filepath" + "strings" agentpkg "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/transcript" ) -// Transcript message type constants - aliases to transcript package for local use. -const ( - transcriptTypeUser = transcript.TypeUser -) - // resolveTranscriptPath determines the correct file path for an agent's session transcript. // Computes the path dynamically from the current repo location for cross-machine portability. func resolveTranscriptPath(ctx context.Context, sessionID string, agent agentpkg.Agent) (string, error) { @@ -34,6 +31,63 @@ func resolveTranscriptPath(ctx context.Context, sessionID string, agent agentpkg return agent.ResolveSessionFile(sessionDir, sessionID), nil } +// searchTranscriptInProjectDirs searches for a session transcript across an agent's +// project directories that could plausibly belong to the current repository. +// Agents like Claude Code and Gemini CLI derive the project directory from the cwd, +// so the transcript may be stored under a different project directory if the session +// was started from a different working directory. +// +// The search is scoped to the agent's base directory (e.g., ~/.claude/projects) and only +// walks immediate subdirectories (plus one extra level for agents like Gemini that nest +// chats under /chats/). +// Only agents implementing SessionBaseDirProvider support this fallback search. +func searchTranscriptInProjectDirs(sessionID string, ag agentpkg.Agent) (string, error) { + provider, ok := agentpkg.AsSessionBaseDirProvider(ag) + if !ok { + return "", fmt.Errorf("fallback transcript search not supported for agent %q", ag.Name()) + } + baseDir, err := provider.GetSessionBaseDir() + if err != nil { + return "", fmt.Errorf("failed to get base directory: %w", err) + } + + // Walk subdirectories with a max depth of 3 (baseDir/project/subdir/file) + // to avoid scanning unrelated project trees. + const maxExtraDepth = 3 + + var found string + walkErr := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // skip inaccessible dirs + } + if !d.IsDir() { + return nil + } + // Limit walk depth using relative path from base + rel, relErr := filepath.Rel(baseDir, path) + if relErr != nil { + return filepath.SkipDir + } + depth := strings.Count(rel, string(filepath.Separator)) + if depth > maxExtraDepth { + return filepath.SkipDir + } + candidate := ag.ResolveSessionFile(path, sessionID) + if _, statErr := os.Stat(candidate); statErr == nil { + found = candidate + return filepath.SkipAll + } + return nil + }) + if walkErr != nil { + return "", fmt.Errorf("failed to search project directories: %w", walkErr) + } + if found != "" { + return found, nil + } + return "", errors.New("transcript not found in any project directory") +} + // AgentTranscriptPath returns the path to a subagent's transcript file. // Subagent transcripts are stored as agent-{agentId}.jsonl in the same directory // as the main transcript. @@ -56,9 +110,9 @@ type userMessageWithToolResults struct { // for the given tool_use_id. This is used to find the checkpoint point for // transcript truncation when rewinding to a task. // Returns the UUID and true if found, empty string and false otherwise. -func FindCheckpointUUID(transcript []transcriptLine, toolUseID string) (string, bool) { - for _, line := range transcript { - if line.Type != transcriptTypeUser { +func FindCheckpointUUID(lines []transcriptLine, toolUseID string) (string, bool) { + for _, line := range lines { + if line.Type != transcript.TypeUser { continue } @@ -81,30 +135,30 @@ func FindCheckpointUUID(transcript []transcriptLine, toolUseID string) (string, // the entire transcript. // //nolint:revive // Exported for testing purposes -func TruncateTranscriptAtUUID(transcript []transcriptLine, uuid string) []transcriptLine { +func TruncateTranscriptAtUUID(lines []transcriptLine, uuid string) []transcriptLine { if uuid == "" { - return transcript + return lines } - for i, line := range transcript { + for i, line := range lines { if line.UUID == uuid { - return transcript[:i+1] + return lines[:i+1] } } // UUID not found, return full transcript - return transcript + return lines } // writeTranscript writes transcript lines to a file in JSONL format. -func writeTranscript(path string, transcript []transcriptLine) error { +func writeTranscript(path string, lines []transcriptLine) error { file, err := os.Create(path) //nolint:gosec // Writing to controlled git metadata path if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer func() { _ = file.Close() }() - for _, line := range transcript { + for _, line := range lines { data, err := json.Marshal(line) if err != nil { return fmt.Errorf("failed to marshal line: %w", err)