From 52766e62f90e2af629b3f85bdc18f000f2d416bf Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:25:19 -0700 Subject: [PATCH 01/20] Add transcript search across project directories Add searchTranscriptInProjectDirs to search for session transcripts across an agent's project directories when the primary lookup fails. This handles cases where sessions were started from subdirectories (different CWD hash). Also fix parameter shadowing of the transcript package by renaming transcript params to lines, and remove the now-unnecessary local type alias. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 750296d02171 --- cmd/entire/cli/transcript.go | 101 +++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 15 deletions(-) diff --git a/cmd/entire/cli/transcript.go b/cmd/entire/cli/transcript.go index 3bb5d9587..b872f4388 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -4,21 +4,19 @@ 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/agent/types" "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 +32,79 @@ func resolveTranscriptPath(ctx context.Context, sessionID string, agent agentpkg return agent.ResolveSessionFile(sessionDir, sessionID), nil } +// agentSessionBaseDirs maps agent names to the base directories that contain +// per-project session subdirectories. Each agent organizes transcripts under +// a per-project subdirectory within its base dir. +var agentSessionBaseDirs = map[types.AgentName]func() (string, error){ + agentpkg.AgentNameClaudeCode: func() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".claude", "projects"), nil + }, + agentpkg.AgentNameGemini: func() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".gemini", "tmp"), 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 subdirectory. +// +// 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/). +func searchTranscriptInProjectDirs(agentName types.AgentName, sessionID string, ag agentpkg.Agent) (string, error) { + baseDirFn, ok := agentSessionBaseDirs[agentName] + if !ok { + return "", fmt.Errorf("fallback transcript search not supported for agent %q", agentName) + } + baseDir, err := baseDirFn() + 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. + baseDirDepth := strings.Count(filepath.Clean(baseDir), string(filepath.Separator)) + 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 + depth := strings.Count(filepath.Clean(path), string(filepath.Separator)) - baseDirDepth + 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 +127,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 +152,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) From a2e7e22433916d21ac95e85fb6f3e42490db9823 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:25:29 -0700 Subject: [PATCH 02/20] Backfill prompt from transcript when hooks don't fire When a session's UserPromptSubmit hook doesn't fire (e.g. Factory AI Droid exec mode), the TurnEnd handler now backfills prompt.txt by extracting the first user prompt from the transcript. This ensures checkpoint metadata includes the prompt even for agents that skip hook invocations. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 031fc80a52d1 --- cmd/entire/cli/lifecycle.go | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) 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 { From 203060f747da9d33e5ec9dc9aec47051ad80b9c5 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:26:08 -0700 Subject: [PATCH 03/20] Add entire attach command for importing existing sessions Implement the `entire attach` command that imports an existing agent session transcript into the Entire tracking system. Features include: - Resolve transcripts across agent types (Claude Code, Gemini CLI, OpenCode, Cursor, Copilot CLI, Factory AI Droid) - Auto-detect agent from transcript when --agent flag is wrong - Normalize Gemini transcripts (convert content arrays to strings) while preserving all other fields - Single-pass transcript metadata extraction (prompt, turns, model) - Create checkpoint with file change detection - Offer to amend the last commit with Entire-Checkpoint trailer - Support --force flag for non-interactive use - Depth-limited directory walking for transcript search fallback Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 97c75100bbac --- cmd/entire/cli/attach.go | 601 ++++++++++++++++++++++++++++++++++ cmd/entire/cli/attach_test.go | 559 +++++++++++++++++++++++++++++++ cmd/entire/cli/root.go | 1 + 3 files changed, 1161 insertions(+) create mode 100644 cmd/entire/cli/attach.go create mode 100644 cmd/entire/cli/attach_test.go diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go new file mode 100644 index 000000000..76fd00149 --- /dev/null +++ b/cmd/entire/cli/attach.go @@ -0,0 +1,601 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "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" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "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/transcript" + "github.com/entireio/cli/cmd/entire/cli/validation" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" + + "github.com/charmbracelet/huh" + "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 registers the +session for future tracking. Use this when hooks failed to fire or weren't +installed when the session started. + +Supported agents: claude-code, gemini, opencode, cursor, copilot-cli, factoryai-droid`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + 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 { + logCtx := logging.WithComponent(ctx, "attach") + + // Validate session ID format + if err := validation.ValidateSessionID(sessionID); err != nil { + return fmt.Errorf("invalid session ID: %w", err) + } + + // Ensure we're in a git repo + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("not a git repository: %w", err) + } + + // Bail out early if repo has no commits (strategy requires HEAD) + if repo, repoErr := strategy.OpenRepository(ctx); repoErr == nil && strategy.IsEmptyRepository(repo) { + return errors.New("repository has no commits yet — make an initial commit before running attach") + } + + // Check session isn't already tracked + store, err := session.NewStateStore(ctx) + if err != nil { + return fmt.Errorf("failed to open session store: %w", err) + } + existing, err := store.Load(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to check existing session: %w", err) + } + if existing != nil { + return fmt.Errorf("session %s is already tracked by Entire", sessionID) + } + + // Resolve agent and transcript path (auto-detect agent if transcript not found) + ag, err := agent.Get(agentName) + if err != nil { + return fmt.Errorf("agent %q not available: %w", agentName, err) + } + + transcriptPath, err := resolveAndValidateTranscript(logCtx, sessionID, agentName, ag) + if err != nil { + // Try other agents to auto-detect + if detectedAg, detectedPath, detectErr := detectAgentByTranscript(logCtx, sessionID, agentName); detectErr == nil { + ag = detectedAg + transcriptPath = detectedPath + logging.Info(logCtx, "auto-detected agent from transcript", "agent", ag.Name()) + fmt.Fprintf(w, "Auto-detected agent: %s\n", ag.Name()) + } else { + return err // return original error + } + } + agentType := ag.Type() + + // Read transcript data + transcriptData, err := ag.ReadTranscript(transcriptPath) + if err != nil { + return fmt.Errorf("failed to read transcript: %w", err) + } + + // Extract modified files from transcript + var modifiedFiles []string + if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { + if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptPath, 0); fileErr != nil { + logging.Warn(logCtx, "failed to extract modified files from transcript", "error", fileErr) + } else { + modifiedFiles = files + } + } + + // Detect file changes via git status (no pre-untracked filter since we don't know pre-state) + changes, err := DetectFileChanges(ctx, nil) + if err != nil { + logging.Warn(logCtx, "failed to detect file changes, checkpoint may be incomplete", "error", err) + } + + // Filter and normalize paths + relModifiedFiles := FilterAndNormalizePaths(modifiedFiles, repoRoot) + var relNewFiles, relDeletedFiles []string + if changes != nil { + relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) + relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) + relModifiedFiles = mergeUnique(relModifiedFiles, FilterAndNormalizePaths(changes.Modified, repoRoot)) + } + + // Filter to uncommitted files only + relModifiedFiles = filterToUncommittedFiles(ctx, relModifiedFiles, repoRoot) + + if err := strategy.EnsureSetup(ctx); err != nil { + return fmt.Errorf("failed to set up strategy: %w", err) + } + + // Create session metadata directory and copy transcript + sessionDir := paths.SessionMetadataDirFromSessionID(sessionID) + sessionDirAbs, err := paths.AbsPath(ctx, sessionDir) + if err != nil { + return fmt.Errorf("failed to resolve session directory path: %w", err) + } + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + // Normalize Gemini transcripts so the content field is a plain string + // (Gemini user messages use [{"text":"..."}] arrays, which the UI can't render). + storedTranscript := transcriptData + if agentType == agent.AgentTypeGemini { + if normalized, normErr := normalizeGeminiTranscript(transcriptData); normErr == nil { + storedTranscript = normalized + } else { + logging.Warn(logCtx, "failed to normalize Gemini transcript, storing raw", "error", normErr) + } + } + logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := os.WriteFile(logFile, storedTranscript, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + // Get git author + author, err := GetGitAuthor(ctx) + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + // Extract transcript metadata (prompt, turns, model) in a single parse pass + meta := extractTranscriptMetadata(transcriptData) + firstPrompt := meta.FirstPrompt + commitMessage := generateCommitMessage(firstPrompt, agentType) + + // Write prompt.txt so SaveStep includes it on the shadow branch + // and CondenseSession can read it for the checkpoint metadata. + if firstPrompt != "" { + promptFile := filepath.Join(sessionDirAbs, paths.PromptFileName) + if err := os.WriteFile(promptFile, []byte(firstPrompt), 0o600); err != nil { + logging.Warn(logCtx, "failed to write prompt file", "error", err) + } + } + + // Initialize session state BEFORE SaveStep so it exists when SaveStep loads it. + // Use logFile (the normalized local copy) as the transcript path so that + // CondenseSession reads the normalized version instead of the raw agent file. + strat := GetStrategy(ctx) + if err := strat.InitializeSession(ctx, sessionID, agentType, logFile, firstPrompt, ""); err != nil { + return fmt.Errorf("failed to initialize session: %w", err) + } + + // Enrich session state with transcript-derived metadata + if err := enrichSessionState(logCtx, sessionID, ag, transcriptData, logFile, meta); err != nil { + return err + } + + // Build step context and save checkpoint. + // Use the local (potentially normalized) transcript so SaveStep stores the cleaned version. + totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) + + stepCtx := strategy.StepContext{ + SessionID: sessionID, + ModifiedFiles: relModifiedFiles, + NewFiles: relNewFiles, + DeletedFiles: relDeletedFiles, + MetadataDir: sessionDir, + MetadataDirAbs: sessionDirAbs, + CommitMessage: commitMessage, + TranscriptPath: logFile, + AuthorName: author.Name, + AuthorEmail: author.Email, + AgentType: agentType, + } + + if err := strat.SaveStep(ctx, stepCtx); err != nil { + return fmt.Errorf("failed to save checkpoint: %w", err) + } + + checkpointIDStr, condenseErr := condenseAndFinalizeSession(logCtx, strat, sessionID) + + // Print confirmation + fmt.Fprintf(w, "Attached session %s\n", sessionID) + if totalChanges > 0 { + fmt.Fprintf(w, " Checkpoint saved with %d file(s)\n", totalChanges) + } else { + fmt.Fprintln(w, " Checkpoint saved (transcript only, no uncommitted file changes detected)") + } + if condenseErr != nil { + fmt.Fprintln(w, " Warning: checkpoint saved on shadow branch only (condensation failed)") + } + fmt.Fprintln(w, " Session is now tracked — future prompts will be captured automatically") + + // Offer to amend the last commit with the checkpoint trailer + if checkpointIDStr != "" { + if err := promptAmendCommit(ctx, w, checkpointIDStr, 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", checkpointIDStr) + } + } + + return nil +} + +// enrichSessionState loads the session state after initialization and populates it with +// transcript-derived metadata (token usage, turn count, model name, duration). +// The meta parameter provides pre-extracted prompt/turn/model data to avoid re-parsing. +func enrichSessionState(ctx context.Context, sessionID string, ag agent.Agent, transcriptData []byte, transcriptPath string, meta transcriptMetadata) error { + state, err := strategy.LoadSessionState(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to load session state: %w", err) + } + if state == nil { + return fmt.Errorf("session state not found after initialization (session_id=%s)", sessionID) + } + state.CLIVersion = versioninfo.Version + state.TranscriptPath = transcriptPath + + if usage := agent.CalculateTokenUsage(ctx, ag, transcriptData, 0, ""); usage != nil { + state.TokenUsage = usage + } + if meta.TurnCount > 0 { + state.SessionTurnCount = meta.TurnCount + } + if meta.Model != "" { + state.ModelName = meta.Model + } + if dur := estimateSessionDuration(transcriptData); dur > 0 { + state.SessionDurationMs = dur + } + + if err := strategy.SaveSessionState(ctx, state); err != nil { + return fmt.Errorf("failed to save session state: %w", err) + } + return nil +} + +// condenseAndFinalizeSession condenses the session to permanent storage and transitions it to IDLE. +// Returns the checkpoint ID string and any condensation error. +func condenseAndFinalizeSession(ctx context.Context, strat *strategy.ManualCommitStrategy, sessionID string) (string, error) { + var checkpointIDStr string + var condenseErr error + if err := strat.CondenseSessionByID(ctx, sessionID); err != nil { + logging.Warn(ctx, "failed to condense session", "error", err, "session_id", sessionID) + condenseErr = err + } + + // Single load serves both checkpoint ID extraction and finalization + state, loadErr := strategy.LoadSessionState(ctx, sessionID) + if loadErr != nil { + logging.Warn(ctx, "failed to load session state after condensation", "error", loadErr, "session_id", sessionID) + return checkpointIDStr, condenseErr + } + if state == nil { + return checkpointIDStr, condenseErr + } + + if condenseErr == nil { + checkpointIDStr = state.LastCheckpointID.String() + } + + now := time.Now() + state.LastInteractionTime = &now + if transErr := strategy.TransitionAndLog(ctx, state, session.EventTurnEnd, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil { + logging.Warn(ctx, "failed to transition session to idle", "error", transErr, "session_id", sessionID) + } + if saveErr := strategy.SaveSessionState(ctx, state); saveErr != nil { + logging.Warn(ctx, "failed to save session state after transition", "error", saveErr, "session_id", sessionID) + } + + return checkpointIDStr, condenseErr +} + +// resolveAndValidateTranscript finds the transcript file for a session, searching alternative +// project directories if needed. +func resolveAndValidateTranscript(ctx context.Context, sessionID string, agentName types.AgentName, ag agent.Agent) (string, error) { + transcriptPath, err := resolveTranscriptPath(ctx, sessionID, ag) + if err != nil { + return "", fmt.Errorf("failed to resolve transcript path: %w", err) + } + // If agent implements TranscriptPreparer, materialize the transcript before checking disk. + if preparer, ok := agent.AsTranscriptPreparer(ag); ok { + if prepErr := preparer.PrepareTranscript(ctx, transcriptPath); prepErr != nil { + logging.Debug(ctx, "PrepareTranscript failed (best-effort)", "error", prepErr) + } + } + // Agents use cwd-derived project directories, so the transcript may be stored under + // a different project directory if the session was started from a different working directory. + if _, statErr := os.Stat(transcriptPath); statErr == nil { + return transcriptPath, nil + } + found, searchErr := searchTranscriptInProjectDirs(agentName, 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?", agentName, 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 + } + if path, resolveErr := resolveAndValidateTranscript(ctx, sessionID, name, ag); resolveErr == nil { + 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, checkpointIDStr string, force bool) error { + // Get HEAD commit info + repo, err := openRepository(ctx) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + headRef, err := repo.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + headCommit, err := repo.CommitObject(headRef.Hash()) + if err != nil { + return fmt.Errorf("failed to get HEAD commit: %w", err) + } + + shortHash := headRef.Hash().String()[:7] + subject := strings.SplitN(headCommit.Message, "\n", 2)[0] + + 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 + } + + // 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 already has Entire-Checkpoint: %s (skipping amend)\n", checkpointIDStr) + return nil + } + } + + // Amend the commit with the checkpoint trailer. + // If the message already has trailers, append on a new line; otherwise add a blank line first. + trimmed := strings.TrimRight(headCommit.Message, "\n") + trailer := fmt.Sprintf("%s: %s", trailers.CheckpointTrailerKey, checkpointIDStr) + var newMessage string + if _, found := trailers.ParseCheckpoint(headCommit.Message); found { + newMessage = fmt.Sprintf("%s\n%s\n", trimmed, trailer) + } else { + newMessage = fmt.Sprintf("%s\n\n%s\n", trimmed, trailer) + } + + cmd := exec.CommandContext(ctx, "git", "commit", "--amend", "-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 +} + +// 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 +} + +// normalizeGeminiTranscript normalizes user message content fields in-place from +// [{"text":"..."}] arrays to plain strings, preserving all other transcript fields +// (timestamps, thoughts, tokens, model, toolCalls, etc.). +func normalizeGeminiTranscript(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 + + return json.MarshalIndent(raw, "", " ") +} + +// estimateSessionDuration estimates session duration in milliseconds from JSONL transcript timestamps. +// The "timestamp" field is a top-level field in JSONL lines (alongside "type", "uuid", "message"), +// NOT inside the "message" object. We parse raw lines since transcript.Line doesn't capture it. +// Returns 0 if timestamps are not available (e.g., Gemini transcripts). +func estimateSessionDuration(data []byte) int64 { + type timestamped struct { + Timestamp string `json:"timestamp"` + } + + var first, last time.Time + for _, rawLine := range bytes.Split(data, []byte("\n")) { + if len(rawLine) == 0 { + continue + } + var ts timestamped + if err := json.Unmarshal(rawLine, &ts); err != nil || ts.Timestamp == "" { + continue + } + parsed, err := time.Parse(time.RFC3339Nano, ts.Timestamp) + if err != nil { + parsed, err = time.Parse(time.RFC3339, ts.Timestamp) + if err != nil { + continue + } + } + if first.IsZero() { + first = parsed + } + last = parsed + } + + if first.IsZero() || last.IsZero() || !last.After(first) { + return 0 + } + return last.Sub(first).Milliseconds() +} diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go new file mode 100644 index 000000000..eafb90f2b --- /dev/null +++ b/cmd/entire/cli/attach_test.go @@ -0,0 +1,559 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "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/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.Fatal("expected error for missing session ID") + } +} + +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) { + tmpDir := setupAttachTestRepo(t) + + sessionID := "test-attach-session-001" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"Write","input":{"file_path":"`+tmpDir+`/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"} +`) + + // Create the file that the transcript says was modified + testutil.WriteFile(t, tmpDir, "hello.txt", "world") + + 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) + } +} + +func TestAttach_SessionAlreadyTracked(t *testing.T) { + setupAttachTestRepo(t) + + sessionID := "test-attach-duplicate" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} +`) + + // Pre-create session state + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := store.Save(context.Background(), &session.State{ + SessionID: sessionID, + 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.Fatal("expected error for already-tracked session") + } +} + +func TestAttach_OutputContainsCheckpointID(t *testing.T) { + tmpDir := setupAttachTestRepo(t) + + sessionID := "test-attach-checkpoint-output" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"Write","input":{"file_path":"`+tmpDir+`/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"} +`) + testutil.WriteFile(t, tmpDir, "hello.txt", "world") + + 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_WritesPromptFile(t *testing.T) { + tmpDir := setupAttachTestRepo(t) + + sessionID := "test-attach-prompt-file" + setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"fix the bug"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":"done"},"uuid":"uuid-2"} +`) + + var out bytes.Buffer + if err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false); err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + // Verify prompt.txt was written to session metadata directory + promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") + promptData, err := os.ReadFile(promptFile) + if err != nil { + t.Fatalf("prompt.txt not found: %v", err) + } + if string(promptData) != "fix the bug" { + t.Errorf("prompt.txt = %q, want %q", string(promptData), "fix the bug") + } +} + +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 TestEstimateSessionDuration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data []byte + wantPos bool + }{ + { + name: "jsonl with timestamps", + data: []byte(`{"type":"user","message":{"role":"user","content":"hi"},"uuid":"u1","timestamp":"2026-01-01T10:00:00.000Z"} +{"type":"assistant","message":{"role":"assistant","content":"hello"},"uuid":"a1","timestamp":"2026-01-01T10:05:00.000Z"} +`), + wantPos: true, + }, + { + name: "no timestamps", + data: []byte("{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"uuid\":\"u1\"}\n"), + wantPos: false, + }, + { + name: "gemini format (no timestamps)", + data: []byte(`{"messages":[{"type":"user","content":"hi"}]}`), + wantPos: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := estimateSessionDuration(tt.data) + if tt.wantPos && got <= 0 { + t.Errorf("estimateSessionDuration() = %d, want > 0", got) + } + if !tt.wantPos && got != 0 { + t.Errorf("estimateSessionDuration() = %d, want 0", got) + } + }) + } +} + +func TestNormalizeGeminiTranscript(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 := normalizeGeminiTranscript(raw) + if err != nil { + t.Fatalf("normalizeGeminiTranscript() 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 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) + } + + // Verify session state was created with Gemini agent type + 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) { + tmpDir := 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) + } + + // Verify prompt.txt was written + promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") + promptData, readErr := os.ReadFile(promptFile) + if readErr != nil { + t.Fatalf("prompt.txt not found: %v", readErr) + } + if string(promptData) != "fix the login bug" { + t.Errorf("prompt.txt = %q, want %q", string(promptData), "fix the login bug") + } +} + +// 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) string { + 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) + return tmpDir +} + +// setupClaudeTranscript creates a fake Claude transcript file and returns the session directory. +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/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()) From 678c63684988cc6fbb4ccf134eca3a9dc846ce6a Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:26:47 -0700 Subject: [PATCH 04/20] Address code review: tighten trailer heuristic, fix lint, refactor - Fix appendCheckpointTrailer false positive on body text containing ": " by using a strict git trailer regex (^[A-Za-z][A-Za-z0-9-]*: ) instead of a loose strings.Contains check - Return error explicitly when OpenRepository fails instead of silently continuing past the empty-repo guard - Add debug logging in detectAgentByTranscript for failed agent attempts to aid troubleshooting - Refactor runAttach into smaller helpers to resolve maintidx lint (validateAttachPreconditions, resolveAgentAndTranscript, collectFileChanges, storeTranscript, printAttachConfirmation) - Wrap json.MarshalIndent return to resolve wrapcheck lint - Remove unnecessary tt := tt loop variable capture (Go 1.22+) - Add test case for body text with ": " not being treated as trailer Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 23d9d11d11e7 --- cmd/entire/cli/attach.go | 320 +++++++++++++++++++++------------- cmd/entire/cli/attach_test.go | 46 +++++ 2 files changed, 240 insertions(+), 126 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 76fd00149..383c9b84a 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "time" @@ -61,130 +62,42 @@ Supported agents: claude-code, gemini, opencode, cursor, copilot-cli, factoryai- func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, force bool) error { logCtx := logging.WithComponent(ctx, "attach") - // Validate session ID format - if err := validation.ValidateSessionID(sessionID); err != nil { - return fmt.Errorf("invalid session ID: %w", err) - } - - // Ensure we're in a git repo - repoRoot, err := paths.WorktreeRoot(ctx) - if err != nil { - return fmt.Errorf("not a git repository: %w", err) - } - - // Bail out early if repo has no commits (strategy requires HEAD) - if repo, repoErr := strategy.OpenRepository(ctx); repoErr == nil && strategy.IsEmptyRepository(repo) { - return errors.New("repository has no commits yet — make an initial commit before running attach") - } - - // Check session isn't already tracked - store, err := session.NewStateStore(ctx) + repoRoot, err := validateAttachPreconditions(ctx, sessionID) if err != nil { - return fmt.Errorf("failed to open session store: %w", err) - } - existing, err := store.Load(ctx, sessionID) - if err != nil { - return fmt.Errorf("failed to check existing session: %w", err) - } - if existing != nil { - return fmt.Errorf("session %s is already tracked by Entire", sessionID) - } - - // Resolve agent and transcript path (auto-detect agent if transcript not found) - ag, err := agent.Get(agentName) - if err != nil { - return fmt.Errorf("agent %q not available: %w", agentName, err) + return err } - transcriptPath, err := resolveAndValidateTranscript(logCtx, sessionID, agentName, ag) + ag, transcriptPath, err := resolveAgentAndTranscript(logCtx, w, sessionID, agentName) if err != nil { - // Try other agents to auto-detect - if detectedAg, detectedPath, detectErr := detectAgentByTranscript(logCtx, sessionID, agentName); detectErr == nil { - ag = detectedAg - transcriptPath = detectedPath - logging.Info(logCtx, "auto-detected agent from transcript", "agent", ag.Name()) - fmt.Fprintf(w, "Auto-detected agent: %s\n", ag.Name()) - } else { - return err // return original error - } + return err } agentType := ag.Type() - // Read transcript data transcriptData, err := ag.ReadTranscript(transcriptPath) if err != nil { return fmt.Errorf("failed to read transcript: %w", err) } - // Extract modified files from transcript - var modifiedFiles []string - if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { - if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptPath, 0); fileErr != nil { - logging.Warn(logCtx, "failed to extract modified files from transcript", "error", fileErr) - } else { - modifiedFiles = files - } - } - - // Detect file changes via git status (no pre-untracked filter since we don't know pre-state) - changes, err := DetectFileChanges(ctx, nil) - if err != nil { - logging.Warn(logCtx, "failed to detect file changes, checkpoint may be incomplete", "error", err) - } - - // Filter and normalize paths - relModifiedFiles := FilterAndNormalizePaths(modifiedFiles, repoRoot) - var relNewFiles, relDeletedFiles []string - if changes != nil { - relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) - relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) - relModifiedFiles = mergeUnique(relModifiedFiles, FilterAndNormalizePaths(changes.Modified, repoRoot)) - } - - // Filter to uncommitted files only - relModifiedFiles = filterToUncommittedFiles(ctx, relModifiedFiles, repoRoot) + relModifiedFiles, relNewFiles, relDeletedFiles := collectFileChanges(ctx, logCtx, ag, transcriptPath, repoRoot) if err := strategy.EnsureSetup(ctx); err != nil { return fmt.Errorf("failed to set up strategy: %w", err) } - // Create session metadata directory and copy transcript - sessionDir := paths.SessionMetadataDirFromSessionID(sessionID) - sessionDirAbs, err := paths.AbsPath(ctx, sessionDir) + logFile, sessionDir, sessionDirAbs, err := storeTranscript(logCtx, ctx, sessionID, agentType, transcriptData) if err != nil { - return fmt.Errorf("failed to resolve session directory path: %w", err) - } - if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { - return fmt.Errorf("failed to create session directory: %w", err) - } - // Normalize Gemini transcripts so the content field is a plain string - // (Gemini user messages use [{"text":"..."}] arrays, which the UI can't render). - storedTranscript := transcriptData - if agentType == agent.AgentTypeGemini { - if normalized, normErr := normalizeGeminiTranscript(transcriptData); normErr == nil { - storedTranscript = normalized - } else { - logging.Warn(logCtx, "failed to normalize Gemini transcript, storing raw", "error", normErr) - } - } - logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) - if err := os.WriteFile(logFile, storedTranscript, 0o600); err != nil { - return fmt.Errorf("failed to write transcript: %w", err) + return err } - // Get git author author, err := GetGitAuthor(ctx) if err != nil { return fmt.Errorf("failed to get git author: %w", err) } - // Extract transcript metadata (prompt, turns, model) in a single parse pass meta := extractTranscriptMetadata(transcriptData) firstPrompt := meta.FirstPrompt commitMessage := generateCommitMessage(firstPrompt, agentType) - // Write prompt.txt so SaveStep includes it on the shadow branch - // and CondenseSession can read it for the checkpoint metadata. if firstPrompt != "" { promptFile := filepath.Join(sessionDirAbs, paths.PromptFileName) if err := os.WriteFile(promptFile, []byte(firstPrompt), 0o600); err != nil { @@ -192,23 +105,16 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ } } - // Initialize session state BEFORE SaveStep so it exists when SaveStep loads it. - // Use logFile (the normalized local copy) as the transcript path so that - // CondenseSession reads the normalized version instead of the raw agent file. strat := GetStrategy(ctx) if err := strat.InitializeSession(ctx, sessionID, agentType, logFile, firstPrompt, ""); err != nil { return fmt.Errorf("failed to initialize session: %w", err) } - // Enrich session state with transcript-derived metadata if err := enrichSessionState(logCtx, sessionID, ag, transcriptData, logFile, meta); err != nil { return err } - // Build step context and save checkpoint. - // Use the local (potentially normalized) transcript so SaveStep stores the cleaned version. totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) - stepCtx := strategy.StepContext{ SessionID: sessionID, ModifiedFiles: relModifiedFiles, @@ -229,7 +135,130 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ checkpointIDStr, condenseErr := condenseAndFinalizeSession(logCtx, strat, sessionID) - // Print confirmation + printAttachConfirmation(w, sessionID, totalChanges, condenseErr) + + if checkpointIDStr != "" { + if err := promptAmendCommit(ctx, w, checkpointIDStr, 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", checkpointIDStr) + } + } + + return nil +} + +// validateAttachPreconditions checks session ID format, git repo state, and duplicate sessions. +func validateAttachPreconditions(ctx context.Context, sessionID string) (string, error) { + if err := validation.ValidateSessionID(sessionID); err != nil { + return "", fmt.Errorf("invalid session ID: %w", err) + } + + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return "", fmt.Errorf("not a git repository: %w", err) + } + + repo, repoErr := strategy.OpenRepository(ctx) + if repoErr != nil { + return "", fmt.Errorf("failed to open repository: %w", repoErr) + } + if strategy.IsEmptyRepository(repo) { + return "", errors.New("repository has no commits yet — make an initial commit before running attach") + } + + store, err := session.NewStateStore(ctx) + if err != nil { + return "", fmt.Errorf("failed to open session store: %w", err) + } + existing, err := store.Load(ctx, sessionID) + if err != nil { + return "", fmt.Errorf("failed to check existing session: %w", err) + } + if existing != nil { + return "", fmt.Errorf("session %s is already tracked by Entire", sessionID) + } + + return repoRoot, nil +} + +// resolveAgentAndTranscript resolves the agent and transcript path, with auto-detection fallback. +func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName) (agent.Agent, string, error) { + ag, err := agent.Get(agentName) + if err != nil { + return nil, "", fmt.Errorf("agent %q not available: %w", agentName, err) + } + + transcriptPath, err := resolveAndValidateTranscript(ctx, sessionID, agentName, ag) + if err != nil { + detectedAg, detectedPath, detectErr := detectAgentByTranscript(ctx, sessionID, agentName) + if detectErr != nil { + return nil, "", err + } + 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 +} + +// collectFileChanges gathers modified, new, and deleted files from both transcript analysis and git status. +func collectFileChanges(ctx, logCtx context.Context, ag agent.Agent, transcriptPath, repoRoot string) (modified, added, deleted []string) { + var transcriptFiles []string + if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { + if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptPath, 0); fileErr != nil { + logging.Warn(logCtx, "failed to extract modified files from transcript", "error", fileErr) + } else { + transcriptFiles = files + } + } + + changes, err := DetectFileChanges(ctx, nil) + if err != nil { + logging.Warn(logCtx, "failed to detect file changes, checkpoint may be incomplete", "error", err) + } + + modified = FilterAndNormalizePaths(transcriptFiles, repoRoot) + if changes != nil { + added = FilterAndNormalizePaths(changes.New, repoRoot) + deleted = FilterAndNormalizePaths(changes.Deleted, repoRoot) + modified = mergeUnique(modified, FilterAndNormalizePaths(changes.Modified, repoRoot)) + } + + modified = filterToUncommittedFiles(ctx, modified, repoRoot) + return modified, added, deleted +} + +// storeTranscript creates the session metadata directory and writes the (optionally normalized) transcript. +func storeTranscript(logCtx, ctx context.Context, sessionID string, agentType types.AgentType, transcriptData []byte) (logFile, sessionDir, sessionDirAbs string, err error) { + sessionDir = paths.SessionMetadataDirFromSessionID(sessionID) + sessionDirAbs, err = paths.AbsPath(ctx, sessionDir) + if err != nil { + return "", "", "", fmt.Errorf("failed to resolve session directory path: %w", err) + } + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return "", "", "", fmt.Errorf("failed to create session directory: %w", err) + } + + storedTranscript := transcriptData + if agentType == agent.AgentTypeGemini { + if normalized, normErr := normalizeGeminiTranscript(transcriptData); normErr == nil { + storedTranscript = normalized + } else { + logging.Warn(logCtx, "failed to normalize Gemini transcript, storing raw", "error", normErr) + } + } + + logFile = filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := os.WriteFile(logFile, storedTranscript, 0o600); err != nil { + return "", "", "", fmt.Errorf("failed to write transcript: %w", err) + } + return logFile, sessionDir, sessionDirAbs, nil +} + +// printAttachConfirmation prints the post-attach status message. +func printAttachConfirmation(w io.Writer, sessionID string, totalChanges int, condenseErr error) { fmt.Fprintf(w, "Attached session %s\n", sessionID) if totalChanges > 0 { fmt.Fprintf(w, " Checkpoint saved with %d file(s)\n", totalChanges) @@ -240,16 +269,6 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ fmt.Fprintln(w, " Warning: checkpoint saved on shadow branch only (condensation failed)") } fmt.Fprintln(w, " Session is now tracked — future prompts will be captured automatically") - - // Offer to amend the last commit with the checkpoint trailer - if checkpointIDStr != "" { - if err := promptAmendCommit(ctx, w, checkpointIDStr, 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", checkpointIDStr) - } - } - - return nil } // enrichSessionState loads the session state after initialization and populates it with @@ -359,9 +378,12 @@ func detectAgentByTranscript(ctx context.Context, sessionID string, skip types.A if err != nil { continue } - if path, resolveErr := resolveAndValidateTranscript(ctx, sessionID, name, ag); resolveErr == nil { - return ag, path, nil + path, resolveErr := resolveAndValidateTranscript(ctx, sessionID, name, 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") } @@ -418,17 +440,11 @@ func promptAmendCommit(ctx context.Context, w io.Writer, checkpointIDStr string, } // Amend the commit with the checkpoint trailer. - // If the message already has trailers, append on a new line; otherwise add a blank line first. - trimmed := strings.TrimRight(headCommit.Message, "\n") - trailer := fmt.Sprintf("%s: %s", trailers.CheckpointTrailerKey, checkpointIDStr) - var newMessage string - if _, found := trailers.ParseCheckpoint(headCommit.Message); found { - newMessage = fmt.Sprintf("%s\n%s\n", trimmed, trailer) - } else { - newMessage = fmt.Sprintf("%s\n\n%s\n", trimmed, trailer) - } + newMessage := appendCheckpointTrailer(headCommit.Message, checkpointIDStr) - cmd := exec.CommandContext(ctx, "git", "commit", "--amend", "-m", newMessage) + // --only ensures this amend updates the commit message only and does not + // accidentally include unrelated staged changes. + 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) } @@ -437,6 +453,54 @@ func promptAmendCommit(ctx context.Context, w io.Writer, checkpointIDStr string, return nil } +// 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", trailers.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" +} + // transcriptMetadata holds metadata extracted from a single transcript parse pass. type transcriptMetadata struct { FirstPrompt string @@ -560,7 +624,11 @@ func normalizeGeminiTranscript(data []byte) ([]byte, error) { } raw["messages"] = rewrittenMessages - return json.MarshalIndent(raw, "", " ") + result, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to re-serialize transcript: %w", err) + } + return result, nil } // estimateSessionDuration estimates session duration in milliseconds from JSONL transcript timestamps. diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index eafb90f2b..69d0599cf 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -408,6 +408,52 @@ func TestExtractFirstPromptFromTranscript_JSONLFormat(t *testing.T) { } } +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 TestAttach_GeminiSubdirectorySession(t *testing.T) { setupAttachTestRepo(t) From 26d0afca96cad68b8e81551f2f579315eade2d22 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:35:06 -0700 Subject: [PATCH 05/20] Refactor attach command: decompose file, move logic to proper packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #688 review feedback by breaking up attach.go (669→459 lines) and relocating misplaced concerns to their proper packages: - Move trailer manipulation (AppendCheckpointTrailer, IsTrailerLine) to trailers package where other trailer operations live - Move normalizeGeminiTranscript to geminicli.NormalizeTranscript since it's Gemini-specific transcript logic - Extract transcript utilities (extractTranscriptMetadata, estimateSessionDuration) to attach_transcript.go - Replace hardcoded agentSessionBaseDirs map with SessionBaseDirProvider optional interface on Agent, implemented by Claude Code, Gemini CLI, Cursor, and Factory AI Droid - Fix storeTranscript parameter ordering: ctx first per Go convention - Distinguish "detection failed" from "no changes found" in attach output - Switch estimateSessionDuration from bytes.Split to bufio.Scanner - Simplify resolveAndValidateTranscript by removing redundant agentName parameter Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 475f14207459 --- cmd/entire/cli/agent/agent.go | 13 + cmd/entire/cli/agent/capabilities.go | 14 + cmd/entire/cli/agent/claudecode/claude.go | 11 + cmd/entire/cli/agent/cursor/cursor.go | 11 + .../agent/factoryaidroid/factoryaidroid.go | 11 + cmd/entire/cli/agent/geminicli/gemini.go | 11 + cmd/entire/cli/agent/geminicli/transcript.go | 82 ++++++ .../cli/agent/geminicli/transcript_test.go | 46 ++++ cmd/entire/cli/attach.go | 260 ++---------------- cmd/entire/cli/attach_test.go | 92 ------- cmd/entire/cli/attach_transcript.go | 99 +++++++ cmd/entire/cli/trailers/trailers.go | 48 ++++ cmd/entire/cli/trailers/trailers_test.go | 70 +++++ cmd/entire/cli/transcript.go | 32 +-- 14 files changed, 447 insertions(+), 353 deletions(-) create mode 100644 cmd/entire/cli/attach_transcript.go 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..95b74ba6c 100644 --- a/cmd/entire/cli/agent/geminicli/transcript.go +++ b/cmd/entire/cli/agent/geminicli/transcript.go @@ -213,6 +213,88 @@ 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.). +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 index 383c9b84a..bc20f7d30 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -1,16 +1,13 @@ package cli import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" - "regexp" "strings" "time" @@ -22,7 +19,6 @@ import ( "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/transcript" "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/versioninfo" @@ -78,13 +74,13 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ return fmt.Errorf("failed to read transcript: %w", err) } - relModifiedFiles, relNewFiles, relDeletedFiles := collectFileChanges(ctx, logCtx, ag, transcriptPath, repoRoot) + relModifiedFiles, relNewFiles, relDeletedFiles, fileDetectionFailed := collectFileChanges(ctx, logCtx, ag, transcriptPath, repoRoot) if err := strategy.EnsureSetup(ctx); err != nil { return fmt.Errorf("failed to set up strategy: %w", err) } - logFile, sessionDir, sessionDirAbs, err := storeTranscript(logCtx, ctx, sessionID, agentType, transcriptData) + logFile, sessionDir, sessionDirAbs, err := storeTranscript(ctx, sessionID, agentType, transcriptData) if err != nil { return err } @@ -135,7 +131,7 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ checkpointIDStr, condenseErr := condenseAndFinalizeSession(logCtx, strat, sessionID) - printAttachConfirmation(w, sessionID, totalChanges, condenseErr) + printAttachConfirmation(w, sessionID, totalChanges, fileDetectionFailed, condenseErr) if checkpointIDStr != "" { if err := promptAmendCommit(ctx, w, checkpointIDStr, force); err != nil { @@ -188,7 +184,7 @@ func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID strin return nil, "", fmt.Errorf("agent %q not available: %w", agentName, err) } - transcriptPath, err := resolveAndValidateTranscript(ctx, sessionID, agentName, ag) + transcriptPath, err := resolveAndValidateTranscript(ctx, sessionID, ag) if err != nil { detectedAg, detectedPath, detectErr := detectAgentByTranscript(ctx, sessionID, agentName) if detectErr != nil { @@ -204,11 +200,13 @@ func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID strin } // collectFileChanges gathers modified, new, and deleted files from both transcript analysis and git status. -func collectFileChanges(ctx, logCtx context.Context, ag agent.Agent, transcriptPath, repoRoot string) (modified, added, deleted []string) { +// Returns detectionFailed=true if file change detection errored (distinct from finding no changes). +func collectFileChanges(ctx, logCtx context.Context, ag agent.Agent, transcriptPath, repoRoot string) (modified, added, deleted []string, detectionFailed bool) { var transcriptFiles []string if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptPath, 0); fileErr != nil { logging.Warn(logCtx, "failed to extract modified files from transcript", "error", fileErr) + detectionFailed = true } else { transcriptFiles = files } @@ -217,6 +215,7 @@ func collectFileChanges(ctx, logCtx context.Context, ag agent.Agent, transcriptP changes, err := DetectFileChanges(ctx, nil) if err != nil { logging.Warn(logCtx, "failed to detect file changes, checkpoint may be incomplete", "error", err) + detectionFailed = true } modified = FilterAndNormalizePaths(transcriptFiles, repoRoot) @@ -227,11 +226,12 @@ func collectFileChanges(ctx, logCtx context.Context, ag agent.Agent, transcriptP } modified = filterToUncommittedFiles(ctx, modified, repoRoot) - return modified, added, deleted + return modified, added, deleted, detectionFailed } // storeTranscript creates the session metadata directory and writes the (optionally normalized) transcript. -func storeTranscript(logCtx, ctx context.Context, sessionID string, agentType types.AgentType, transcriptData []byte) (logFile, sessionDir, sessionDirAbs string, err error) { +func storeTranscript(ctx context.Context, sessionID string, agentType types.AgentType, transcriptData []byte) (logFile, sessionDir, sessionDirAbs string, err error) { + logCtx := logging.WithComponent(ctx, "attach") sessionDir = paths.SessionMetadataDirFromSessionID(sessionID) sessionDirAbs, err = paths.AbsPath(ctx, sessionDir) if err != nil { @@ -243,7 +243,7 @@ func storeTranscript(logCtx, ctx context.Context, sessionID string, agentType ty storedTranscript := transcriptData if agentType == agent.AgentTypeGemini { - if normalized, normErr := normalizeGeminiTranscript(transcriptData); normErr == nil { + if normalized, normErr := geminicli.NormalizeTranscript(transcriptData); normErr == nil { storedTranscript = normalized } else { logging.Warn(logCtx, "failed to normalize Gemini transcript, storing raw", "error", normErr) @@ -258,11 +258,14 @@ func storeTranscript(logCtx, ctx context.Context, sessionID string, agentType ty } // printAttachConfirmation prints the post-attach status message. -func printAttachConfirmation(w io.Writer, sessionID string, totalChanges int, condenseErr error) { +func printAttachConfirmation(w io.Writer, sessionID string, totalChanges int, fileDetectionFailed bool, condenseErr error) { fmt.Fprintf(w, "Attached session %s\n", sessionID) - if totalChanges > 0 { + switch { + case totalChanges > 0: fmt.Fprintf(w, " Checkpoint saved with %d file(s)\n", totalChanges) - } else { + case fileDetectionFailed: + fmt.Fprintln(w, " Checkpoint saved (transcript only, file change detection failed)") + default: fmt.Fprintln(w, " Checkpoint saved (transcript only, no uncommitted file changes detected)") } if condenseErr != nil { @@ -306,6 +309,8 @@ func enrichSessionState(ctx context.Context, sessionID string, ag agent.Agent, t // condenseAndFinalizeSession condenses the session to permanent storage and transitions it to IDLE. // Returns the checkpoint ID string and any condensation error. +// Note: accepts *strategy.ManualCommitStrategy directly because CondenseSessionByID is intentionally +// not on the Strategy interface — it's a strategy-specific repair/attach operation. func condenseAndFinalizeSession(ctx context.Context, strat *strategy.ManualCommitStrategy, sessionID string) (string, error) { var checkpointIDStr string var condenseErr error @@ -342,7 +347,7 @@ func condenseAndFinalizeSession(ctx context.Context, strat *strategy.ManualCommi // resolveAndValidateTranscript finds the transcript file for a session, searching alternative // project directories if needed. -func resolveAndValidateTranscript(ctx context.Context, sessionID string, agentName types.AgentName, ag agent.Agent) (string, error) { +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) @@ -358,13 +363,13 @@ func resolveAndValidateTranscript(ctx context.Context, sessionID string, agentNa if _, statErr := os.Stat(transcriptPath); statErr == nil { return transcriptPath, nil } - found, searchErr := searchTranscriptInProjectDirs(agentName, sessionID, ag) + 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?", agentName, sessionID) + 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 @@ -378,7 +383,7 @@ func detectAgentByTranscript(ctx context.Context, sessionID string, skip types.A if err != nil { continue } - path, resolveErr := resolveAndValidateTranscript(ctx, sessionID, name, ag) + path, resolveErr := resolveAndValidateTranscript(ctx, sessionID, ag) if resolveErr != nil { logging.Debug(ctx, "auto-detect: agent did not match", "agent", string(name), "error", resolveErr) continue @@ -440,7 +445,7 @@ func promptAmendCommit(ctx context.Context, w io.Writer, checkpointIDStr string, } // Amend the commit with the checkpoint trailer. - newMessage := appendCheckpointTrailer(headCommit.Message, checkpointIDStr) + newMessage := trailers.AppendCheckpointTrailer(headCommit.Message, checkpointIDStr) // --only ensures this amend updates the commit message only and does not // accidentally include unrelated staged changes. @@ -452,218 +457,3 @@ func promptAmendCommit(ctx context.Context, w io.Writer, checkpointIDStr string, fmt.Fprintf(w, "Amended commit %s with Entire-Checkpoint: %s\n", shortHash, checkpointIDStr) return nil } - -// 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", trailers.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" -} - -// 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 -} - -// normalizeGeminiTranscript normalizes user message content fields in-place from -// [{"text":"..."}] arrays to plain strings, preserving all other transcript fields -// (timestamps, thoughts, tokens, model, toolCalls, etc.). -func normalizeGeminiTranscript(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 -} - -// estimateSessionDuration estimates session duration in milliseconds from JSONL transcript timestamps. -// The "timestamp" field is a top-level field in JSONL lines (alongside "type", "uuid", "message"), -// NOT inside the "message" object. We parse raw lines since transcript.Line doesn't capture it. -// Returns 0 if timestamps are not available (e.g., Gemini transcripts). -func estimateSessionDuration(data []byte) int64 { - type timestamped struct { - Timestamp string `json:"timestamp"` - } - - var first, last time.Time - for _, rawLine := range bytes.Split(data, []byte("\n")) { - if len(rawLine) == 0 { - continue - } - var ts timestamped - if err := json.Unmarshal(rawLine, &ts); err != nil || ts.Timestamp == "" { - continue - } - parsed, err := time.Parse(time.RFC3339Nano, ts.Timestamp) - if err != nil { - parsed, err = time.Parse(time.RFC3339, ts.Timestamp) - if err != nil { - continue - } - } - if first.IsZero() { - first = parsed - } - last = parsed - } - - if first.IsZero() || last.IsZero() || !last.After(first) { - return 0 - } - return last.Sub(first).Milliseconds() -} diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 69d0599cf..760bb8365 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -3,7 +3,6 @@ package cli import ( "bytes" "context" - "encoding/json" "os" "path/filepath" "regexp" @@ -341,51 +340,6 @@ func TestEstimateSessionDuration(t *testing.T) { } } -func TestNormalizeGeminiTranscript(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 := normalizeGeminiTranscript(raw) - if err != nil { - t.Fatalf("normalizeGeminiTranscript() 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 TestExtractFirstPromptFromTranscript_GeminiFormat(t *testing.T) { t.Parallel() @@ -408,52 +362,6 @@ func TestExtractFirstPromptFromTranscript_JSONLFormat(t *testing.T) { } } -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 TestAttach_GeminiSubdirectorySession(t *testing.T) { setupAttachTestRepo(t) diff --git a/cmd/entire/cli/attach_transcript.go b/cmd/entire/cli/attach_transcript.go new file mode 100644 index 000000000..cf2f2607a --- /dev/null +++ b/cmd/entire/cli/attach_transcript.go @@ -0,0 +1,99 @@ +package cli + +import ( + "bufio" + "bytes" + "encoding/json" + "time" + + "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 +} + +// estimateSessionDuration estimates session duration in milliseconds from JSONL transcript timestamps. +// The "timestamp" field is a top-level field in JSONL lines (alongside "type", "uuid", "message"), +// NOT inside the "message" object. We parse raw lines since transcript.Line doesn't capture it. +// Uses bufio.Scanner for memory efficiency with large transcripts. +// Returns 0 if timestamps are not available (e.g., Gemini transcripts). +func estimateSessionDuration(data []byte) int64 { + type timestamped struct { + Timestamp string `json:"timestamp"` + } + + var first, last time.Time + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + rawLine := scanner.Bytes() + if len(rawLine) == 0 { + continue + } + var ts timestamped + if err := json.Unmarshal(rawLine, &ts); err != nil || ts.Timestamp == "" { + continue + } + parsed, err := time.Parse(time.RFC3339Nano, ts.Timestamp) + if err != nil { + parsed, err = time.Parse(time.RFC3339, ts.Timestamp) + if err != nil { + continue + } + } + if first.IsZero() { + first = parsed + } + last = parsed + } + + if first.IsZero() || last.IsZero() || !last.After(first) { + return 0 + } + return last.Sub(first).Milliseconds() +} 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 b872f4388..23d2aed48 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -12,7 +12,6 @@ import ( "strings" agentpkg "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/transcript" ) @@ -32,41 +31,22 @@ func resolveTranscriptPath(ctx context.Context, sessionID string, agent agentpkg return agent.ResolveSessionFile(sessionDir, sessionID), nil } -// agentSessionBaseDirs maps agent names to the base directories that contain -// per-project session subdirectories. Each agent organizes transcripts under -// a per-project subdirectory within its base dir. -var agentSessionBaseDirs = map[types.AgentName]func() (string, error){ - agentpkg.AgentNameClaudeCode: func() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - return filepath.Join(home, ".claude", "projects"), nil - }, - agentpkg.AgentNameGemini: func() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - return filepath.Join(home, ".gemini", "tmp"), 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 subdirectory. +// 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/). -func searchTranscriptInProjectDirs(agentName types.AgentName, sessionID string, ag agentpkg.Agent) (string, error) { - baseDirFn, ok := agentSessionBaseDirs[agentName] +// 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", agentName) + return "", fmt.Errorf("fallback transcript search not supported for agent %q", ag.Name()) } - baseDir, err := baseDirFn() + baseDir, err := provider.GetSessionBaseDir() if err != nil { return "", fmt.Errorf("failed to get base directory: %w", err) } From f378525c24435f8132ec2809dabddc398b7bed40 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:03:53 -0700 Subject: [PATCH 06/20] Simplify: fix Scanner bug, remove dual-context, deduplicate trailer logic - Revert estimateSessionDuration from bufio.Scanner back to bytes.Split: Scanner has a 64KB default line limit that silently truncates large JSONL lines (tool results can easily exceed this). Since data is already in memory, bytes.Split has no memory overhead and no limit. - Remove dual-context (ctx, logCtx) from collectFileChanges, matching the fix already applied to storeTranscript. No other function in the codebase takes two context parameters. - Replace addCheckpointTrailer in manual_commit_hooks.go with a one-line delegation to trailers.AppendCheckpointTrailer, eliminating 55 lines of duplicated trailer-appending logic. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 874d71c3e4a0 --- cmd/entire/cli/attach.go | 5 +- cmd/entire/cli/attach_transcript.go | 6 +- .../cli/strategy/manual_commit_hooks.go | 57 +------------------ 3 files changed, 6 insertions(+), 62 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index bc20f7d30..d2c7dcb6d 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -74,7 +74,7 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ return fmt.Errorf("failed to read transcript: %w", err) } - relModifiedFiles, relNewFiles, relDeletedFiles, fileDetectionFailed := collectFileChanges(ctx, logCtx, ag, transcriptPath, repoRoot) + relModifiedFiles, relNewFiles, relDeletedFiles, fileDetectionFailed := collectFileChanges(ctx, ag, transcriptPath, repoRoot) if err := strategy.EnsureSetup(ctx); err != nil { return fmt.Errorf("failed to set up strategy: %w", err) @@ -201,7 +201,8 @@ func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID strin // collectFileChanges gathers modified, new, and deleted files from both transcript analysis and git status. // Returns detectionFailed=true if file change detection errored (distinct from finding no changes). -func collectFileChanges(ctx, logCtx context.Context, ag agent.Agent, transcriptPath, repoRoot string) (modified, added, deleted []string, detectionFailed bool) { +func collectFileChanges(ctx context.Context, ag agent.Agent, transcriptPath, repoRoot string) (modified, added, deleted []string, detectionFailed bool) { + logCtx := logging.WithComponent(ctx, "attach") var transcriptFiles []string if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptPath, 0); fileErr != nil { diff --git a/cmd/entire/cli/attach_transcript.go b/cmd/entire/cli/attach_transcript.go index cf2f2607a..2aee48ff6 100644 --- a/cmd/entire/cli/attach_transcript.go +++ b/cmd/entire/cli/attach_transcript.go @@ -1,7 +1,6 @@ package cli import ( - "bufio" "bytes" "encoding/json" "time" @@ -61,7 +60,6 @@ func extractTranscriptMetadata(data []byte) transcriptMetadata { // estimateSessionDuration estimates session duration in milliseconds from JSONL transcript timestamps. // The "timestamp" field is a top-level field in JSONL lines (alongside "type", "uuid", "message"), // NOT inside the "message" object. We parse raw lines since transcript.Line doesn't capture it. -// Uses bufio.Scanner for memory efficiency with large transcripts. // Returns 0 if timestamps are not available (e.g., Gemini transcripts). func estimateSessionDuration(data []byte) int64 { type timestamped struct { @@ -69,9 +67,7 @@ func estimateSessionDuration(data []byte) int64 { } var first, last time.Time - scanner := bufio.NewScanner(bytes.NewReader(data)) - for scanner.Scan() { - rawLine := scanner.Bytes() + for _, rawLine := range bytes.Split(data, []byte("\n")) { if len(rawLine) == 0 { continue } 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. From 0b201cf323283eb2de0c7fc58b3465cc6274f782 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:12:00 -0700 Subject: [PATCH 07/20] Update NormalizeTranscript comment Entire-Checkpoint: 7e8f69399bbe --- cmd/entire/cli/agent/geminicli/transcript.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/entire/cli/agent/geminicli/transcript.go b/cmd/entire/cli/agent/geminicli/transcript.go index 95b74ba6c..2ae72d723 100644 --- a/cmd/entire/cli/agent/geminicli/transcript.go +++ b/cmd/entire/cli/agent/geminicli/transcript.go @@ -216,6 +216,12 @@ func GetLastMessageIDFromFile(path string) (string, error) { // 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 { From 8f598880266782eeaa2cfd460bd81c42c1355432 Mon Sep 17 00:00:00 2001 From: computermode <2917645+computermode@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:36:44 -0700 Subject: [PATCH 08/20] Add attach tests for Cursor and Factory AI Droid agents Cursor and Factory AI Droid both use JSONL transcripts and support GetSessionBaseDir for fallback transcript search, but had no attach test coverage. Add tests verifying: - Cursor flat layout attach (CLI-style .jsonl) - Cursor nested layout attach (IDE-style /.jsonl) - Factory AI Droid flat layout attach Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 5d90be021b38 --- cmd/entire/cli/attach_test.go | 147 +++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 760bb8365..b6653a69b 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -11,8 +11,10 @@ import ( "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/geminicli" // register 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" ) @@ -475,6 +477,147 @@ func TestAttach_GeminiSuccess(t *testing.T) { } } +func TestAttach_CursorSuccess(t *testing.T) { + tmpDir := 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) + } + + output := out.String() + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", output) + } + + 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) + } + + // Verify prompt.txt was written + promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") + promptData, err := os.ReadFile(promptFile) + if err != nil { + t.Fatalf("prompt.txt not found: %v", err) + } + if string(promptData) != "add dark mode" { + t.Errorf("prompt.txt = %q, want %q", string(promptData), "add dark mode") + } +} + +func TestAttach_FactoryAIDroidSuccess(t *testing.T) { + tmpDir := 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) + } + + output := out.String() + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", output) + } + + 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) + } + + // Verify prompt.txt was written + promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") + promptData, err := os.ReadFile(promptFile) + if err != nil { + t.Fatalf("prompt.txt not found: %v", err) + } + if string(promptData) != "deploy to staging" { + t.Errorf("prompt.txt = %q, want %q", string(promptData), "deploy to staging") + } +} + +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) string { From a59872cc43f1593e18f19a954e3f02884f2a9b44 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 18:14:15 +0100 Subject: [PATCH 09/20] slightly better wording to highlight it should move out there, but is fine for now Entire-Checkpoint: ad0e0de4488f --- cmd/entire/cli/attach.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index d2c7dcb6d..ea2e6ccce 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -310,8 +310,9 @@ func enrichSessionState(ctx context.Context, sessionID string, ag agent.Agent, t // condenseAndFinalizeSession condenses the session to permanent storage and transitions it to IDLE. // Returns the checkpoint ID string and any condensation error. -// Note: accepts *strategy.ManualCommitStrategy directly because CondenseSessionByID is intentionally -// not on the Strategy interface — it's a strategy-specific repair/attach operation. +// Note: accepts *strategy.ManualCommitStrategy directly because checkpoint storage is conceptually +// strategy-independent but currently lives on the strategy struct. Extracting it would require +// publicizing several private methods — worth doing if a second strategy ever appears. func condenseAndFinalizeSession(ctx context.Context, strat *strategy.ManualCommitStrategy, sessionID string) (string, error) { var checkpointIDStr string var condenseErr error From 428d4d657519565fe552efda10658d78bcda441e Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 18:15:45 +0100 Subject: [PATCH 10/20] show full help if a param is missing Entire-Checkpoint: cf20cbb8aa04 --- cmd/entire/cli/attach.go | 4 +++- cmd/entire/cli/attach_test.go | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index ea2e6ccce..4488321ae 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -41,8 +41,10 @@ session for future tracking. Use this when hooks failed to fire or weren't installed when the session started. Supported agents: claude-code, gemini, opencode, cursor, copilot-cli, factoryai-droid`, - Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return cmd.Help() + } if checkDisabledGuard(cmd.Context(), cmd.OutOrStdout()) { return nil } diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index b6653a69b..4b603e83e 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -30,8 +30,11 @@ func TestAttach_MissingSessionID(t *testing.T) { cmd.SetErr(&out) err := cmd.Execute() - if err == nil { - t.Fatal("expected error for missing session ID") + 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()) } } From cfc6a3729b3319fbfc62de1b7210c7ca8c311008 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 18:16:20 +0100 Subject: [PATCH 11/20] surface detection error Entire-Checkpoint: 5f61b54c322c --- cmd/entire/cli/attach.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 4488321ae..c8519cd00 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -190,7 +190,7 @@ func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID strin if err != nil { detectedAg, detectedPath, detectErr := detectAgentByTranscript(ctx, sessionID, agentName) if detectErr != nil { - return nil, "", err + return nil, "", fmt.Errorf("%w (also tried auto-detecting other agents: %w)", err, detectErr) } ag = detectedAg transcriptPath = detectedPath From 54efea4abcb82f98ea1acac687ce88e263bc28ee Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 18:16:45 +0100 Subject: [PATCH 12/20] more proof path walking that also supports `\\` Entire-Checkpoint: e8d91a564f1d --- cmd/entire/cli/transcript.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/transcript.go b/cmd/entire/cli/transcript.go index 23d2aed48..bb52cb07a 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -53,7 +53,6 @@ func searchTranscriptInProjectDirs(sessionID string, ag agentpkg.Agent) (string, // Walk subdirectories with a max depth of 3 (baseDir/project/subdir/file) // to avoid scanning unrelated project trees. - baseDirDepth := strings.Count(filepath.Clean(baseDir), string(filepath.Separator)) const maxExtraDepth = 3 var found string @@ -64,8 +63,12 @@ func searchTranscriptInProjectDirs(sessionID string, ag agentpkg.Agent) (string, if !d.IsDir() { return nil } - // Limit walk depth - depth := strings.Count(filepath.Clean(path), string(filepath.Separator)) - baseDirDepth + // 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 } From 4ea8c6ce23cdf0c459c56b8b5180c9813881ccb3 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 19:50:48 +0100 Subject: [PATCH 13/20] make attach less restricted Entire-Checkpoint: dd8d525fe7ea --- cmd/entire/cli/attach.go | 406 ++++++++++++++++------------------ cmd/entire/cli/attach_test.go | 131 +++-------- 2 files changed, 223 insertions(+), 314 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index c8519cd00..f50679da1 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -7,15 +7,15 @@ import ( "io" "os" "os/exec" - "path/filepath" "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/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trailers" @@ -36,9 +36,12 @@ func newAttachCmd() *cobra.Command { 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 registers the -session for future tracking. Use this when hooks failed to fire or weren't -installed when the session started. +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 { @@ -60,123 +63,222 @@ Supported agents: claude-code, gemini, opencode, cursor, copilot-cli, factoryai- func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, force bool) error { logCtx := logging.WithComponent(ctx, "attach") - repoRoot, err := validateAttachPreconditions(ctx, sessionID) + existingState, err := validateAttachPreconditions(ctx, sessionID) if err != nil { return err } - ag, transcriptPath, err := resolveAgentAndTranscript(logCtx, w, sessionID, agentName) + // 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, 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 — from existing state or flag + auto-detection. + var ag agent.Agent + if existingState != nil { + ag, err = resolveAgentForState(existingState, agentName) + } else { + ag, _, err = resolveAgentAndTranscript(logCtx, w, sessionID, agentName) + } if err != nil { return err } - agentType := ag.Type() + + transcriptPath, err := resolveAndValidateTranscript(logCtx, sessionID, ag) + if err != nil { + return fmt.Errorf("transcript not found for session %s: %w", sessionID, err) + } transcriptData, err := ag.ReadTranscript(transcriptPath) if err != nil { return fmt.Errorf("failed to read transcript: %w", err) } - relModifiedFiles, relNewFiles, relDeletedFiles, fileDetectionFailed := collectFileChanges(ctx, ag, transcriptPath, repoRoot) + // 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) - if err := strategy.EnsureSetup(ctx); err != nil { - return fmt.Errorf("failed to set up strategy: %w", err) + // Determine checkpoint ID: reuse from HEAD if one exists, otherwise generate new. + checkpointID, isExistingCheckpoint, err := resolveCheckpointID(logCtx) + if err != nil { + return err } - logFile, sessionDir, sessionDirAbs, err := storeTranscript(ctx, sessionID, agentType, transcriptData) + // Write directly to entire/checkpoints/v1. + repo, err := openRepository(ctx) if err != nil { return err } + store := cpkg.NewGitStore(repo) author, err := GetGitAuthor(ctx) if err != nil { return fmt.Errorf("failed to get git author: %w", err) } - meta := extractTranscriptMetadata(transcriptData) - firstPrompt := meta.FirstPrompt - commitMessage := generateCommitMessage(firstPrompt, agentType) + var prompts []string + if meta.FirstPrompt != "" { + prompts = []string{meta.FirstPrompt} + } - if firstPrompt != "" { - promptFile := filepath.Join(sessionDirAbs, paths.PromptFileName) - if err := os.WriteFile(promptFile, []byte(firstPrompt), 0o600); err != nil { - logging.Warn(logCtx, "failed to write prompt file", "error", err) - } + var tokenUsage *agent.TokenUsage + if usage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, ""); usage != nil { + tokenUsage = usage } - strat := GetStrategy(ctx) - if err := strat.InitializeSession(ctx, sessionID, agentType, logFile, firstPrompt, ""); err != nil { - return fmt.Errorf("failed to initialize session: %w", err) + if err := store.WriteCommitted(ctx, cpkg.WriteCommittedOptions{ + CheckpointID: checkpointID, + SessionID: sessionID, + Strategy: "manual-commit", + 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) } - if err := enrichSessionState(logCtx, sessionID, ag, transcriptData, logFile, meta); err != nil { - return err + // Create or update session state. + if err := saveAttachSessionState(logCtx, 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) + } else { + fmt.Fprintf(w, " Created checkpoint %s\n", checkpointID) + } + + cpIDStr := checkpointID.String() + if err := promptAmendCommit(logCtx, w, 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 +} + +// 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(ctx context.Context) (id.CheckpointID, bool, error) { + repo, err := openRepository(ctx) + if err != nil { + return id.EmptyCheckpointID, false, err + } + headRef, err := repo.Head() + if err != nil { + return id.EmptyCheckpointID, false, fmt.Errorf("failed to get HEAD: %w", err) + } + headCommit, err := repo.CommitObject(headRef.Hash()) + if err != nil { + return id.EmptyCheckpointID, false, fmt.Errorf("failed to get HEAD commit: %w", err) } - totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) - stepCtx := strategy.StepContext{ - SessionID: sessionID, - ModifiedFiles: relModifiedFiles, - NewFiles: relNewFiles, - DeletedFiles: relDeletedFiles, - MetadataDir: sessionDir, - MetadataDirAbs: sessionDirAbs, - CommitMessage: commitMessage, - TranscriptPath: logFile, - AuthorName: author.Name, - AuthorEmail: author.Email, - AgentType: agentType, + existing := trailers.ParseAllCheckpoints(headCommit.Message) + if len(existing) > 0 { + // Use the last checkpoint ID on HEAD. + return existing[len(existing)-1], true, nil } - if err := strat.SaveStep(ctx, stepCtx); err != nil { - return fmt.Errorf("failed to save checkpoint: %w", err) + cpID, err := id.Generate() + if err != nil { + return id.EmptyCheckpointID, false, fmt.Errorf("failed to generate checkpoint ID: %w", err) } + return cpID, false, nil +} - checkpointIDStr, condenseErr := condenseAndFinalizeSession(logCtx, strat, sessionID) +// saveAttachSessionState creates or updates the session state file for the attached session. +func saveAttachSessionState(ctx context.Context, 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) + } - printAttachConfirmation(w, sessionID, totalChanges, fileDetectionFailed, condenseErr) + state, err := stateStore.Load(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to load session state: %w", err) + } - if checkpointIDStr != "" { - if err := promptAmendCommit(ctx, w, checkpointIDStr, 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", checkpointIDStr) + now := time.Now() + if state == nil { + state = &session.State{ + SessionID: sessionID, + StartedAt: now, } } + state.CLIVersion = versioninfo.Version + 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 + } + // Note: session duration is not estimated here because we don't have the + // raw transcript data. The token usage and turn count are sufficient metadata. + + 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, git repo state, and duplicate sessions. -func validateAttachPreconditions(ctx context.Context, sessionID string) (string, error) { +// 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, sessionID string) (*session.State, error) { if err := validation.ValidateSessionID(sessionID); err != nil { - return "", fmt.Errorf("invalid session ID: %w", err) - } - - repoRoot, err := paths.WorktreeRoot(ctx) - if err != nil { - return "", fmt.Errorf("not a git repository: %w", err) + return nil, fmt.Errorf("invalid session ID: %w", err) } repo, repoErr := strategy.OpenRepository(ctx) if repoErr != nil { - return "", fmt.Errorf("failed to open repository: %w", repoErr) + return nil, fmt.Errorf("failed to open repository: %w", repoErr) } if strategy.IsEmptyRepository(repo) { - return "", errors.New("repository has no commits yet — make an initial commit before running attach") + 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 "", fmt.Errorf("failed to open session store: %w", err) + return nil, fmt.Errorf("failed to open session store: %w", err) } existing, err := store.Load(ctx, sessionID) if err != nil { - return "", fmt.Errorf("failed to check existing session: %w", err) - } - if existing != nil { - return "", fmt.Errorf("session %s is already tracked by Entire", sessionID) + return nil, fmt.Errorf("failed to check existing session: %w", err) } - return repoRoot, nil + return existing, nil } // resolveAgentAndTranscript resolves the agent and transcript path, with auto-detection fallback. @@ -201,152 +303,25 @@ func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID strin return ag, transcriptPath, nil } -// collectFileChanges gathers modified, new, and deleted files from both transcript analysis and git status. -// Returns detectionFailed=true if file change detection errored (distinct from finding no changes). -func collectFileChanges(ctx context.Context, ag agent.Agent, transcriptPath, repoRoot string) (modified, added, deleted []string, detectionFailed bool) { - logCtx := logging.WithComponent(ctx, "attach") - var transcriptFiles []string - if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { - if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptPath, 0); fileErr != nil { - logging.Warn(logCtx, "failed to extract modified files from transcript", "error", fileErr) - detectionFailed = true - } else { - transcriptFiles = files +// resolveAgentForState resolves the agent from session state's AgentType, +// falling back to the --agent flag if the state has no type. +func resolveAgentForState(state *session.State, agentName types.AgentName) (agent.Agent, error) { + if state.AgentType != "" { + for _, name := range agent.List() { + ag, err := agent.Get(name) + if err != nil { + continue + } + if ag.Type() == state.AgentType { + return ag, nil + } } } - - changes, err := DetectFileChanges(ctx, nil) + ag, err := agent.Get(agentName) if err != nil { - logging.Warn(logCtx, "failed to detect file changes, checkpoint may be incomplete", "error", err) - detectionFailed = true - } - - modified = FilterAndNormalizePaths(transcriptFiles, repoRoot) - if changes != nil { - added = FilterAndNormalizePaths(changes.New, repoRoot) - deleted = FilterAndNormalizePaths(changes.Deleted, repoRoot) - modified = mergeUnique(modified, FilterAndNormalizePaths(changes.Modified, repoRoot)) + return nil, fmt.Errorf("agent %q not available: %w", agentName, err) } - - modified = filterToUncommittedFiles(ctx, modified, repoRoot) - return modified, added, deleted, detectionFailed -} - -// storeTranscript creates the session metadata directory and writes the (optionally normalized) transcript. -func storeTranscript(ctx context.Context, sessionID string, agentType types.AgentType, transcriptData []byte) (logFile, sessionDir, sessionDirAbs string, err error) { - logCtx := logging.WithComponent(ctx, "attach") - sessionDir = paths.SessionMetadataDirFromSessionID(sessionID) - sessionDirAbs, err = paths.AbsPath(ctx, sessionDir) - if err != nil { - return "", "", "", fmt.Errorf("failed to resolve session directory path: %w", err) - } - if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { - return "", "", "", fmt.Errorf("failed to create session directory: %w", err) - } - - storedTranscript := transcriptData - if agentType == 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) - } - } - - logFile = filepath.Join(sessionDirAbs, paths.TranscriptFileName) - if err := os.WriteFile(logFile, storedTranscript, 0o600); err != nil { - return "", "", "", fmt.Errorf("failed to write transcript: %w", err) - } - return logFile, sessionDir, sessionDirAbs, nil -} - -// printAttachConfirmation prints the post-attach status message. -func printAttachConfirmation(w io.Writer, sessionID string, totalChanges int, fileDetectionFailed bool, condenseErr error) { - fmt.Fprintf(w, "Attached session %s\n", sessionID) - switch { - case totalChanges > 0: - fmt.Fprintf(w, " Checkpoint saved with %d file(s)\n", totalChanges) - case fileDetectionFailed: - fmt.Fprintln(w, " Checkpoint saved (transcript only, file change detection failed)") - default: - fmt.Fprintln(w, " Checkpoint saved (transcript only, no uncommitted file changes detected)") - } - if condenseErr != nil { - fmt.Fprintln(w, " Warning: checkpoint saved on shadow branch only (condensation failed)") - } - fmt.Fprintln(w, " Session is now tracked — future prompts will be captured automatically") -} - -// enrichSessionState loads the session state after initialization and populates it with -// transcript-derived metadata (token usage, turn count, model name, duration). -// The meta parameter provides pre-extracted prompt/turn/model data to avoid re-parsing. -func enrichSessionState(ctx context.Context, sessionID string, ag agent.Agent, transcriptData []byte, transcriptPath string, meta transcriptMetadata) error { - state, err := strategy.LoadSessionState(ctx, sessionID) - if err != nil { - return fmt.Errorf("failed to load session state: %w", err) - } - if state == nil { - return fmt.Errorf("session state not found after initialization (session_id=%s)", sessionID) - } - state.CLIVersion = versioninfo.Version - state.TranscriptPath = transcriptPath - - if usage := agent.CalculateTokenUsage(ctx, ag, transcriptData, 0, ""); usage != nil { - state.TokenUsage = usage - } - if meta.TurnCount > 0 { - state.SessionTurnCount = meta.TurnCount - } - if meta.Model != "" { - state.ModelName = meta.Model - } - if dur := estimateSessionDuration(transcriptData); dur > 0 { - state.SessionDurationMs = dur - } - - if err := strategy.SaveSessionState(ctx, state); err != nil { - return fmt.Errorf("failed to save session state: %w", err) - } - return nil -} - -// condenseAndFinalizeSession condenses the session to permanent storage and transitions it to IDLE. -// Returns the checkpoint ID string and any condensation error. -// Note: accepts *strategy.ManualCommitStrategy directly because checkpoint storage is conceptually -// strategy-independent but currently lives on the strategy struct. Extracting it would require -// publicizing several private methods — worth doing if a second strategy ever appears. -func condenseAndFinalizeSession(ctx context.Context, strat *strategy.ManualCommitStrategy, sessionID string) (string, error) { - var checkpointIDStr string - var condenseErr error - if err := strat.CondenseSessionByID(ctx, sessionID); err != nil { - logging.Warn(ctx, "failed to condense session", "error", err, "session_id", sessionID) - condenseErr = err - } - - // Single load serves both checkpoint ID extraction and finalization - state, loadErr := strategy.LoadSessionState(ctx, sessionID) - if loadErr != nil { - logging.Warn(ctx, "failed to load session state after condensation", "error", loadErr, "session_id", sessionID) - return checkpointIDStr, condenseErr - } - if state == nil { - return checkpointIDStr, condenseErr - } - - if condenseErr == nil { - checkpointIDStr = state.LastCheckpointID.String() - } - - now := time.Now() - state.LastInteractionTime = &now - if transErr := strategy.TransitionAndLog(ctx, state, session.EventTurnEnd, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil { - logging.Warn(ctx, "failed to transition session to idle", "error", transErr, "session_id", sessionID) - } - if saveErr := strategy.SaveSessionState(ctx, state); saveErr != nil { - logging.Warn(ctx, "failed to save session state after transition", "error", saveErr, "session_id", sessionID) - } - - return checkpointIDStr, condenseErr + return ag, nil } // resolveAndValidateTranscript finds the transcript file for a session, searching alternative @@ -356,14 +331,11 @@ func resolveAndValidateTranscript(ctx context.Context, sessionID string, ag agen if err != nil { return "", fmt.Errorf("failed to resolve transcript path: %w", err) } - // If agent implements TranscriptPreparer, materialize the transcript before checking disk. if preparer, ok := agent.AsTranscriptPreparer(ag); ok { if prepErr := preparer.PrepareTranscript(ctx, transcriptPath); prepErr != nil { logging.Debug(ctx, "PrepareTranscript failed (best-effort)", "error", prepErr) } } - // Agents use cwd-derived project directories, so the transcript may be stored under - // a different project directory if the session was started from a different working directory. if _, statErr := os.Stat(transcriptPath); statErr == nil { return transcriptPath, nil } @@ -400,7 +372,6 @@ func detectAgentByTranscript(ctx context.Context, sessionID string, skip types.A // 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, checkpointIDStr string, force bool) error { - // Get HEAD commit info repo, err := openRepository(ctx) if err != nil { return fmt.Errorf("failed to open repository: %w", err) @@ -417,6 +388,14 @@ func promptAmendCommit(ctx context.Context, w io.Writer, checkpointIDStr string, shortHash := headRef.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 @@ -440,19 +419,8 @@ func promptAmendCommit(ctx context.Context, w io.Writer, checkpointIDStr string, return nil } - // 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 already has Entire-Checkpoint: %s (skipping amend)\n", checkpointIDStr) - return nil - } - } - - // Amend the commit with the checkpoint trailer. newMessage := trailers.AppendCheckpointTrailer(headCommit.Message, checkpointIDStr) - // --only ensures this amend updates the commit message only and does not - // accidentally include unrelated staged changes. 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) diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 4b603e83e..3a86668d8 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -54,17 +54,13 @@ func TestAttach_TranscriptNotFound(t *testing.T) { } func TestAttach_Success(t *testing.T) { - tmpDir := setupAttachTestRepo(t) + setupAttachTestRepo(t) sessionID := "test-attach-session-001" setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"Write","input":{"file_path":"`+tmpDir+`/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":"done"},"uuid":"uuid-2"} `) - // Create the file that the transcript says was modified - testutil.WriteFile(t, tmpDir, "hello.txt", "world") - var out bytes.Buffer err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false) if err != nil { @@ -90,27 +86,32 @@ func TestAttach_Success(t *testing.T) { 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(t *testing.T) { +func TestAttach_SessionAlreadyTracked_NoCheckpoint(t *testing.T) { setupAttachTestRepo(t) sessionID := "test-attach-duplicate" setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} +{"type":"assistant","message":{"role":"assistant","content":"hi"},"uuid":"uuid-2"} `) - // Pre-create session state + // 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) @@ -118,20 +119,31 @@ func TestAttach_SessionAlreadyTracked(t *testing.T) { var out bytes.Buffer err = runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false) - if err == nil { - t.Fatal("expected error for already-tracked session") + 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) { - tmpDir := setupAttachTestRepo(t) + setupAttachTestRepo(t) sessionID := "test-attach-checkpoint-output" setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"Write","input":{"file_path":"`+tmpDir+`/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":"done"},"uuid":"uuid-2"} `) - testutil.WriteFile(t, tmpDir, "hello.txt", "world") var out bytes.Buffer err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false) @@ -148,30 +160,6 @@ func TestAttach_OutputContainsCheckpointID(t *testing.T) { } } -func TestAttach_WritesPromptFile(t *testing.T) { - tmpDir := setupAttachTestRepo(t) - - sessionID := "test-attach-prompt-file" - setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"fix the bug"},"uuid":"uuid-1"} -{"type":"assistant","message":{"role":"assistant","content":"done"},"uuid":"uuid-2"} -`) - - var out bytes.Buffer - if err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, false); err != nil { - t.Fatalf("runAttach failed: %v", err) - } - - // Verify prompt.txt was written to session metadata directory - promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") - promptData, err := os.ReadFile(promptFile) - if err != nil { - t.Fatalf("prompt.txt not found: %v", err) - } - if string(promptData) != "fix the bug" { - t.Errorf("prompt.txt = %q, want %q", string(promptData), "fix the bug") - } -} - func TestAttach_PopulatesTokenUsage(t *testing.T) { setupAttachTestRepo(t) @@ -370,12 +358,9 @@ func TestExtractFirstPromptFromTranscript_JSONLFormat(t *testing.T) { 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) @@ -383,14 +368,11 @@ func TestAttach_GeminiSubdirectorySession(t *testing.T) { 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) @@ -405,7 +387,6 @@ func TestAttach_GeminiSubdirectorySession(t *testing.T) { t.Errorf("expected 'Attached session' in output, got: %s", output) } - // Verify session state was created with Gemini agent type store, storeErr := session.NewStateStore(context.Background()) if storeErr != nil { t.Fatal(storeErr) @@ -426,9 +407,8 @@ func TestAttach_GeminiSubdirectorySession(t *testing.T) { } func TestAttach_GeminiSuccess(t *testing.T) { - tmpDir := setupAttachTestRepo(t) + setupAttachTestRepo(t) - // Create Gemini transcript in expected project dir geminiDir := t.TempDir() t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", geminiDir) @@ -450,7 +430,6 @@ func TestAttach_GeminiSuccess(t *testing.T) { 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) @@ -468,30 +447,18 @@ func TestAttach_GeminiSuccess(t *testing.T) { if state.SessionTurnCount != 1 { t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount) } - - // Verify prompt.txt was written - promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") - promptData, readErr := os.ReadFile(promptFile) - if readErr != nil { - t.Fatalf("prompt.txt not found: %v", readErr) - } - if string(promptData) != "fix the login bug" { - t.Errorf("prompt.txt = %q, want %q", string(promptData), "fix the login bug") - } } func TestAttach_CursorSuccess(t *testing.T) { - tmpDir := setupAttachTestRepo(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) } @@ -502,9 +469,8 @@ func TestAttach_CursorSuccess(t *testing.T) { 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) + if !strings.Contains(out.String(), "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", out.String()) } store, err := session.NewStateStore(context.Background()) @@ -524,30 +490,18 @@ func TestAttach_CursorSuccess(t *testing.T) { if state.SessionTurnCount != 1 { t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount) } - - // Verify prompt.txt was written - promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") - promptData, err := os.ReadFile(promptFile) - if err != nil { - t.Fatalf("prompt.txt not found: %v", err) - } - if string(promptData) != "add dark mode" { - t.Errorf("prompt.txt = %q, want %q", string(promptData), "add dark mode") - } } func TestAttach_FactoryAIDroidSuccess(t *testing.T) { - tmpDir := setupAttachTestRepo(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) } @@ -558,9 +512,8 @@ func TestAttach_FactoryAIDroidSuccess(t *testing.T) { 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) + if !strings.Contains(out.String(), "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", out.String()) } store, err := session.NewStateStore(context.Background()) @@ -580,16 +533,6 @@ func TestAttach_FactoryAIDroidSuccess(t *testing.T) { if state.SessionTurnCount != 1 { t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount) } - - // Verify prompt.txt was written - promptFile := filepath.Join(tmpDir, ".entire", "metadata", sessionID, "prompt.txt") - promptData, err := os.ReadFile(promptFile) - if err != nil { - t.Fatalf("prompt.txt not found: %v", err) - } - if string(promptData) != "deploy to staging" { - t.Errorf("prompt.txt = %q, want %q", string(promptData), "deploy to staging") - } } func TestAttach_CursorNestedLayout(t *testing.T) { @@ -601,7 +544,6 @@ func TestAttach_CursorNestedLayout(t *testing.T) { 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) @@ -623,7 +565,7 @@ func TestAttach_CursorNestedLayout(t *testing.T) { // 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) string { +func setupAttachTestRepo(t *testing.T) { t.Helper() tmpDir := t.TempDir() testutil.InitRepo(t, tmpDir) @@ -632,10 +574,9 @@ func setupAttachTestRepo(t *testing.T) string { testutil.GitCommit(t, tmpDir, "init") t.Chdir(tmpDir) enableEntire(t, tmpDir) - return tmpDir } -// setupClaudeTranscript creates a fake Claude transcript file and returns the session directory. +// setupClaudeTranscript creates a fake Claude transcript file. func setupClaudeTranscript(t *testing.T, sessionID, content string) { t.Helper() claudeDir := t.TempDir() From bf6ffd8d455d2a28e94c2da350ecf598e7f51a38 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 20:46:21 +0100 Subject: [PATCH 14/20] proper wording Entire-Checkpoint: 3afc9eb5cf12 --- cmd/entire/cli/attach.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index f50679da1..bf42af81d 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -163,10 +163,10 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ fmt.Fprintf(w, "Attached session %s\n", sessionID) if isExistingCheckpoint { fmt.Fprintf(w, " Added to existing checkpoint %s\n", checkpointID) - } else { - fmt.Fprintf(w, " Created checkpoint %s\n", checkpointID) + return nil } + fmt.Fprintf(w, " Created checkpoint %s\n", checkpointID) cpIDStr := checkpointID.String() if err := promptAmendCommit(logCtx, w, cpIDStr, force); err != nil { logging.Warn(logCtx, "failed to amend commit", "error", err) From 26a402b13128f4475b3dca17028e9237ff8c930c Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 21:01:16 +0100 Subject: [PATCH 15/20] add integration tests validating scenarios Entire-Checkpoint: f0e624c0890f --- .../cli/integration_test/attach_test.go | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 cmd/entire/cli/integration_test/attach_test.go 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") + } +} From a557cd23a9d1c4b71f0d53160faa946a3409d7f2 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 21:15:26 +0100 Subject: [PATCH 16/20] initialize logging Entire-Checkpoint: 6d946c1c4716 --- cmd/entire/cli/attach.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index bf42af81d..8b4c56551 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -61,6 +61,12 @@ Supported agents: claude-code, gemini, opencode, cursor, copilot-cli, factoryai- } 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") existingState, err := validateAttachPreconditions(ctx, sessionID) @@ -331,6 +337,9 @@ func resolveAndValidateTranscript(ctx context.Context, sessionID string, ag agen 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) From 4a14503326c4b36b5531daf5c2fe0389ca6ca11f Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 21:26:20 +0100 Subject: [PATCH 17/20] bring back longer fixtures Entire-Checkpoint: e5f0c2c4d25f --- cmd/entire/cli/attach_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 3a86668d8..463affb08 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -57,8 +57,10 @@ func TestAttach_Success(t *testing.T) { setupAttachTestRepo(t) sessionID := "test-attach-session-001" - setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} -{"type":"assistant","message":{"role":"assistant","content":"done"},"uuid":"uuid-2"} + 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 @@ -99,8 +101,8 @@ func TestAttach_SessionAlreadyTracked_NoCheckpoint(t *testing.T) { setupAttachTestRepo(t) sessionID := "test-attach-duplicate" - setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} -{"type":"assistant","message":{"role":"assistant","content":"hi"},"uuid":"uuid-2"} + 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 @@ -141,8 +143,10 @@ func TestAttach_OutputContainsCheckpointID(t *testing.T) { setupAttachTestRepo(t) sessionID := "test-attach-checkpoint-output" - setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"uuid-1"} -{"type":"assistant","message":{"role":"assistant","content":"done"},"uuid":"uuid-2"} + 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 From c2b093771a3abbc214df4962f6d1f427bc059e7e Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 21:37:24 +0100 Subject: [PATCH 18/20] restore removed comments Entire-Checkpoint: f33ca9ae76d0 --- cmd/entire/cli/attach_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 463affb08..894dc8e56 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -88,6 +88,7 @@ func TestAttach_Success(t *testing.T) { 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) @@ -362,9 +363,12 @@ func TestExtractFirstPromptFromTranscript_JSONLFormat(t *testing.T) { 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) @@ -372,11 +376,14 @@ func TestAttach_GeminiSubdirectorySession(t *testing.T) { 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) @@ -413,6 +420,7 @@ func TestAttach_GeminiSubdirectorySession(t *testing.T) { 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) @@ -434,6 +442,7 @@ func TestAttach_GeminiSuccess(t *testing.T) { 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) @@ -460,9 +469,11 @@ func TestAttach_CursorSuccess(t *testing.T) { 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) } @@ -503,9 +514,11 @@ func TestAttach_FactoryAIDroidSuccess(t *testing.T) { 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) } @@ -548,6 +561,7 @@ func TestAttach_CursorNestedLayout(t *testing.T) { 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) From 86f120ddb83a2577bd00a4c400a430067d17b39d Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 23:02:34 +0100 Subject: [PATCH 19/20] simplified Entire-Checkpoint: ba1485e7ea35 --- cmd/entire/cli/attach.go | 149 +++++++++++----------------- cmd/entire/cli/attach_test.go | 41 -------- cmd/entire/cli/attach_transcript.go | 39 -------- 3 files changed, 60 insertions(+), 169 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 8b4c56551..0bef658f2 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -23,6 +23,8 @@ import ( "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" ) @@ -69,7 +71,18 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ logCtx := logging.WithComponent(ctx, "attach") - existingState, err := validateAttachPreconditions(ctx, sessionID) + // 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 } @@ -78,29 +91,19 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ 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, cpID, force); err != nil { + 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 — from existing state or flag + auto-detection. - var ag agent.Agent - if existingState != nil { - ag, err = resolveAgentForState(existingState, agentName) - } else { - ag, _, err = resolveAgentAndTranscript(logCtx, w, sessionID, agentName) - } + // Resolve agent and transcript path. + ag, transcriptPath, err := resolveAgentAndTranscript(logCtx, w, sessionID, agentName, existingState) if err != nil { return err } - transcriptPath, err := resolveAndValidateTranscript(logCtx, sessionID, ag) - if err != nil { - return fmt.Errorf("transcript not found for session %s: %w", sessionID, err) - } - transcriptData, err := ag.ReadTranscript(transcriptPath) if err != nil { return fmt.Errorf("failed to read transcript: %w", err) @@ -119,16 +122,9 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ meta := extractTranscriptMetadata(transcriptData) // Determine checkpoint ID: reuse from HEAD if one exists, otherwise generate new. - checkpointID, isExistingCheckpoint, err := resolveCheckpointID(logCtx) - if err != nil { - return err - } + checkpointID, isExistingCheckpoint := resolveCheckpointID(headCommit) // Write directly to entire/checkpoints/v1. - repo, err := openRepository(ctx) - if err != nil { - return err - } store := cpkg.NewGitStore(repo) author, err := GetGitAuthor(ctx) @@ -141,15 +137,12 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ prompts = []string{meta.FirstPrompt} } - var tokenUsage *agent.TokenUsage - if usage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, ""); usage != nil { - tokenUsage = usage - } + tokenUsage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, "") if err := store.WriteCommitted(ctx, cpkg.WriteCommittedOptions{ CheckpointID: checkpointID, SessionID: sessionID, - Strategy: "manual-commit", + Strategy: strategy.StrategyNameManualCommit, Transcript: storedTranscript, Prompts: prompts, AuthorName: author.Name, @@ -162,7 +155,7 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ } // Create or update session state. - if err := saveAttachSessionState(logCtx, sessionID, ag.Type(), transcriptPath, checkpointID, meta, tokenUsage); err != nil { + if err := saveAttachSessionState(logCtx, existingState, sessionID, ag.Type(), transcriptPath, checkpointID, meta, tokenUsage); err != nil { logging.Warn(logCtx, "failed to save session state", "error", err) } @@ -174,7 +167,7 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ fmt.Fprintf(w, " Created checkpoint %s\n", checkpointID) cpIDStr := checkpointID.String() - if err := promptAmendCommit(logCtx, w, cpIDStr, force); err != nil { + 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) } @@ -182,50 +175,48 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ return 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(ctx context.Context) (id.CheckpointID, bool, error) { - repo, err := openRepository(ctx) - if err != nil { - return id.EmptyCheckpointID, false, err - } +// getHeadCommit returns the HEAD commit object. +func getHeadCommit(repo *git.Repository) (*object.Commit, error) { headRef, err := repo.Head() if err != nil { - return id.EmptyCheckpointID, false, fmt.Errorf("failed to get HEAD: %w", err) + return nil, fmt.Errorf("failed to get HEAD: %w", err) } - headCommit, err := repo.CommitObject(headRef.Hash()) + commit, err := repo.CommitObject(headRef.Hash()) if err != nil { - return id.EmptyCheckpointID, false, fmt.Errorf("failed to get HEAD commit: %w", err) + 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 { - // Use the last checkpoint ID on HEAD. - return existing[len(existing)-1], true, nil + return existing[len(existing)-1], true } cpID, err := id.Generate() if err != nil { - return id.EmptyCheckpointID, false, fmt.Errorf("failed to generate checkpoint ID: %w", err) + // 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, nil + return cpID, false } // saveAttachSessionState creates or updates the session state file for the attached session. -func saveAttachSessionState(ctx context.Context, sessionID string, agentType types.AgentType, transcriptPath string, checkpointID id.CheckpointID, meta transcriptMetadata, tokenUsage *agent.TokenUsage) error { +// 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) } - state, err := stateStore.Load(ctx, sessionID) - if err != nil { - return fmt.Errorf("failed to load session state: %w", err) - } - now := time.Now() + state := existingState if state == nil { state = &session.State{ SessionID: sessionID, @@ -251,8 +242,6 @@ func saveAttachSessionState(ctx context.Context, sessionID string, agentType typ if tokenUsage != nil { state.TokenUsage = tokenUsage } - // Note: session duration is not estimated here because we don't have the - // raw transcript data. The token usage and turn count are sufficient metadata. if err := stateStore.Save(ctx, state); err != nil { return fmt.Errorf("failed to save session state: %w", err) @@ -262,15 +251,11 @@ func saveAttachSessionState(ctx context.Context, sessionID string, agentType typ // 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, sessionID string) (*session.State, error) { +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) } - repo, repoErr := strategy.OpenRepository(ctx) - if repoErr != nil { - return nil, fmt.Errorf("failed to open repository: %w", repoErr) - } if strategy.IsEmptyRepository(repo) { return nil, errors.New("repository has no commits yet — make an initial commit before running attach") } @@ -287,15 +272,18 @@ func validateAttachPreconditions(ctx context.Context, sessionID string) (*sessio return existing, nil } -// resolveAgentAndTranscript resolves the agent and transcript path, with auto-detection fallback. -func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName) (agent.Agent, string, error) { - ag, err := agent.Get(agentName) +// 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, "", fmt.Errorf("agent %q not available: %w", agentName, err) + 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) @@ -309,19 +297,15 @@ func resolveAgentAndTranscript(ctx context.Context, w io.Writer, sessionID strin return ag, transcriptPath, nil } -// resolveAgentForState resolves the agent from session state's AgentType, -// falling back to the --agent flag if the state has no type. -func resolveAgentForState(state *session.State, agentName types.AgentName) (agent.Agent, error) { - if state.AgentType != "" { - for _, name := range agent.List() { - ag, err := agent.Get(name) - if err != nil { - continue - } - if ag.Type() == state.AgentType { - return ag, 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 { @@ -380,21 +364,8 @@ func detectAgentByTranscript(ctx context.Context, sessionID string, skip types.A // 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, checkpointIDStr string, force bool) error { - repo, err := openRepository(ctx) - if err != nil { - return fmt.Errorf("failed to open repository: %w", err) - } - headRef, err := repo.Head() - if err != nil { - return fmt.Errorf("failed to get HEAD: %w", err) - } - headCommit, err := repo.CommitObject(headRef.Hash()) - if err != nil { - return fmt.Errorf("failed to get HEAD commit: %w", err) - } - - shortHash := headRef.Hash().String()[:7] +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. diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 894dc8e56..a8fe6e19d 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -297,47 +297,6 @@ func TestExtractModelFromTranscript(t *testing.T) { } } -func TestEstimateSessionDuration(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - data []byte - wantPos bool - }{ - { - name: "jsonl with timestamps", - data: []byte(`{"type":"user","message":{"role":"user","content":"hi"},"uuid":"u1","timestamp":"2026-01-01T10:00:00.000Z"} -{"type":"assistant","message":{"role":"assistant","content":"hello"},"uuid":"a1","timestamp":"2026-01-01T10:05:00.000Z"} -`), - wantPos: true, - }, - { - name: "no timestamps", - data: []byte("{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"uuid\":\"u1\"}\n"), - wantPos: false, - }, - { - name: "gemini format (no timestamps)", - data: []byte(`{"messages":[{"type":"user","content":"hi"}]}`), - wantPos: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := estimateSessionDuration(tt.data) - if tt.wantPos && got <= 0 { - t.Errorf("estimateSessionDuration() = %d, want > 0", got) - } - if !tt.wantPos && got != 0 { - t.Errorf("estimateSessionDuration() = %d, want 0", got) - } - }) - } -} - func TestExtractFirstPromptFromTranscript_GeminiFormat(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/attach_transcript.go b/cmd/entire/cli/attach_transcript.go index 2aee48ff6..ea7d16859 100644 --- a/cmd/entire/cli/attach_transcript.go +++ b/cmd/entire/cli/attach_transcript.go @@ -1,9 +1,7 @@ package cli import ( - "bytes" "encoding/json" - "time" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/transcript" @@ -56,40 +54,3 @@ func extractTranscriptMetadata(data []byte) transcriptMetadata { return meta } - -// estimateSessionDuration estimates session duration in milliseconds from JSONL transcript timestamps. -// The "timestamp" field is a top-level field in JSONL lines (alongside "type", "uuid", "message"), -// NOT inside the "message" object. We parse raw lines since transcript.Line doesn't capture it. -// Returns 0 if timestamps are not available (e.g., Gemini transcripts). -func estimateSessionDuration(data []byte) int64 { - type timestamped struct { - Timestamp string `json:"timestamp"` - } - - var first, last time.Time - for _, rawLine := range bytes.Split(data, []byte("\n")) { - if len(rawLine) == 0 { - continue - } - var ts timestamped - if err := json.Unmarshal(rawLine, &ts); err != nil || ts.Timestamp == "" { - continue - } - parsed, err := time.Parse(time.RFC3339Nano, ts.Timestamp) - if err != nil { - parsed, err = time.Parse(time.RFC3339, ts.Timestamp) - if err != nil { - continue - } - } - if first.IsZero() { - first = parsed - } - last = parsed - } - - if first.IsZero() || last.IsZero() || !last.After(first) { - return 0 - } - return last.Sub(first).Milliseconds() -} From f68656d18e03de3e4fca3d35811ed815dd836899 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 20 Mar 2026 23:08:22 +0100 Subject: [PATCH 20/20] mark manual attached sessions Entire-Checkpoint: dbdf516703c9 --- cmd/entire/cli/attach.go | 1 + cmd/entire/cli/session/state.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 0bef658f2..fd5c0ad75 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -225,6 +225,7 @@ func saveAttachSessionState(ctx context.Context, existingState *session.State, s } state.CLIVersion = versioninfo.Version + state.AttachedManually = true state.AgentType = agentType state.TranscriptPath = transcriptPath state.LastCheckpointID = checkpointID 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"`