Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
734fa1a
full e2e tests using real agent, basic tests
Soph Feb 14, 2026
dc8ac33
review feedback
Soph Feb 14, 2026
edc1580
copilot review comments addressed
Soph Feb 14, 2026
71644aa
1:1 checkpoint to commit
Soph Feb 13, 2026
a99e7b8
local review feedback
Soph Feb 13, 2026
7c0d2a8
reflect that we now have two commits per checkpoint folder
Soph Feb 13, 2026
59e5aba
and more minor fixes, should be good now
Soph Feb 13, 2026
0419f1b
better handling of multi manual commit
Soph Feb 13, 2026
6a3b681
more tests and content aware overlap check
Soph Feb 13, 2026
e78caaa
more test coverage / documentation
Soph Feb 13, 2026
26c3570
extracted content aware logic, more logging
Soph Feb 13, 2026
57e529d
added an overview diagram with scenarios and mermaid diagramms
Soph Feb 14, 2026
d88560a
added content aware cary forward not only file name based
Soph Feb 14, 2026
9da669d
more flows / updates to latest changes
Soph Feb 14, 2026
4c20622
review feedback
Soph Feb 14, 2026
c108ffd
integration tests matching all checkpoint scenarios
Soph Feb 14, 2026
3abe692
review feedback, one more case tested
Soph Feb 14, 2026
5118ce1
add checkpoint validation including transcript check
Soph Feb 14, 2026
8af0e74
align checkpoint update message format
khaong Feb 15, 2026
50e8075
fix: consolidate GetGitAuthorFromRepo and add global config fallback
khaong Feb 15, 2026
5ae3d96
review feedback: document invariants and add partial failure test
khaong Feb 15, 2026
345f020
fix: include error in transcript read failure log
khaong Feb 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ entire-test
test_claude.txt
cmd/entire/entire
docs/requirements
docs/plans
docs/plans
docs/reviews
19 changes: 9 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ All strategies implement:

Sessions track their lifecycle through phases managed by a state machine in `session/phase.go`:

**Phases:** `ACTIVE`, `ACTIVE_COMMITTED`, `IDLE`, `ENDED`
**Phases:** `ACTIVE`, `IDLE`, `ENDED`

**Events:**
- `TurnStart` - Agent begins a turn (UserPromptSubmit hook)
Expand All @@ -339,12 +339,11 @@ Sessions track their lifecycle through phases managed by a state machine in `ses
**Key transitions:**
- `IDLE + TurnStart → ACTIVE` - Agent starts working
- `ACTIVE + TurnEnd → IDLE` - Agent finishes turn
- `ACTIVE + GitCommit → ACTIVE_COMMITTED` - User commits while agent is working (condensation deferred)
- `ACTIVE_COMMITTED + TurnEnd → IDLE` - Agent finishes after commit (condense now)
- `ACTIVE + GitCommit → ACTIVE` - User commits while agent is working (condense immediately)
- `IDLE + GitCommit → IDLE` - User commits between turns (condense immediately)
- `ENDED + GitCommit → ENDED` - Post-session commit (condense if files touched)

The state machine emits **actions** (e.g., `ActionCondense`, `ActionMigrateShadowBranch`, `ActionDeferCondensation`) that hook handlers dispatch to strategy-specific implementations.
The state machine emits **actions** (e.g., `ActionCondense`, `ActionUpdateLastInteraction`) that hook handlers dispatch to strategy-specific implementations.

#### Metadata Structure

Expand Down Expand Up @@ -426,18 +425,18 @@ Both strategies use a **12-hex-char random checkpoint ID** (e.g., `a3b2c4d5e6f7`
**Bidirectional linking:**

```
User commit → Metadata (two approaches):
Approach 1: Extract "Entire-Checkpoint: a3b2c4d5e6f7" trailer
→ Look up a3/b2c4d5e6f7/ directory on entire/checkpoints/v1 branch

Approach 2: Extract "Entire-Checkpoint: a3b2c4d5e6f7" trailer
→ Search entire/checkpoints/v1 commit history for "Checkpoint: a3b2c4d5e6f7" subject
User commit → Metadata:
Extract "Entire-Checkpoint: a3b2c4d5e6f7" trailer
→ Read a3/b2c4d5e6f7/ directory from entire/checkpoints/v1 tree at HEAD

Metadata → User commits:
Given checkpoint ID a3b2c4d5e6f7
→ Search user branch history for commits with "Entire-Checkpoint: a3b2c4d5e6f7" trailer
```

Note: Commit subjects on `entire/checkpoints/v1` (e.g., `Checkpoint: a3b2c4d5e6f7`) are
for human readability in `git log` only. The CLI always reads from the tree at HEAD.

**Example:**
```
User's commit (on main branch):
Expand Down
38 changes: 38 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ type Store interface {

// ListCommitted lists all committed checkpoints.
ListCommitted(ctx context.Context) ([]CommittedInfo, error)

// UpdateCommitted replaces the transcript, prompts, and context for an existing
// committed checkpoint. Used at stop time to finalize checkpoints with the full
// session transcript (prompt to stop event).
// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error
}

// WriteTemporaryResult contains the result of writing a temporary checkpoint.
Expand Down Expand Up @@ -255,6 +261,9 @@ type WriteCommittedOptions struct {
// Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor")
Agent agent.AgentType

// TurnID correlates checkpoints from the same agent turn.
TurnID string

// Transcript position at checkpoint start - tracks what was added during this checkpoint
TranscriptIdentifierAtStart string // Last identifier when checkpoint started (UUID for Claude, message ID for Gemini)
CheckpointTranscriptStart int // Transcript line offset at start of this checkpoint's data
Expand Down Expand Up @@ -283,6 +292,30 @@ type WriteCommittedOptions struct {
SessionTranscriptPath string
}

// UpdateCommittedOptions contains options for updating an existing committed checkpoint.
// Uses replace semantics: the transcript, prompts, and context are fully replaced,
// not appended. At stop time we have the complete session transcript and want every
// checkpoint to contain it identically.
type UpdateCommittedOptions struct {
// CheckpointID identifies the checkpoint to update
CheckpointID id.CheckpointID

// SessionID identifies which session slot to update within the checkpoint
SessionID string

// Transcript is the full session transcript (replaces existing)
Transcript []byte

// Prompts contains all user prompts (replaces existing)
Prompts []string

// Context is the updated context.md content (replaces existing)
Context []byte

// Agent identifies the agent type (needed for transcript chunking)
Agent agent.AgentType
}

// CommittedInfo contains summary information about a committed checkpoint.
type CommittedInfo struct {
// CheckpointID is the stable 12-hex-char identifier
Expand Down Expand Up @@ -345,6 +378,11 @@ type CommittedMetadata struct {
// Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor")
Agent agent.AgentType `json:"agent,omitempty"`

// TurnID correlates checkpoints from the same agent turn.
// When a turn's work spans multiple commits, each gets its own checkpoint
// but they share the same TurnID for future aggregation/deduplication.
TurnID string `json:"turn_id,omitempty"`

// Task checkpoint fields (only populated for task checkpoints)
IsTask bool `json:"is_task,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Expand Down
196 changes: 192 additions & 4 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/entireio/cli/redact"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
Expand Down Expand Up @@ -339,6 +340,7 @@ func (s *GitStore) writeSessionToSubdirectory(opts WriteCommittedOptions, sessio
CheckpointsCount: opts.CheckpointsCount,
FilesTouched: opts.FilesTouched,
Agent: opts.Agent,
TurnID: opts.TurnID,
IsTask: opts.IsTask,
ToolUseID: opts.ToolUseID,
TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart,
Expand Down Expand Up @@ -992,7 +994,7 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint
return err
}

authorName, authorEmail := getGitAuthorFromRepo(s.repo)
authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", checkpointID, existingMetadata.SessionID)
newCommitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail)
if err != nil {
Expand All @@ -1008,6 +1010,178 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint
return nil
}

// UpdateCommitted replaces the transcript, prompts, and context for an existing
// committed checkpoint. Uses replace semantics: the full session transcript is
// written, replacing whatever was stored at initial condensation time.
//
// This is called at stop time to finalize all checkpoints from the current turn
// with the complete session transcript (from prompt to stop event).
//
// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOptions) error {
if opts.CheckpointID.IsEmpty() {
return errors.New("invalid update options: checkpoint ID is required")
}

// Ensure sessions branch exists
if err := s.ensureSessionsBranch(); err != nil {
return fmt.Errorf("failed to ensure sessions branch: %w", err)
}

// Get current branch tip and flatten tree
ref, entries, err := s.getSessionsBranchEntries()
if err != nil {
return err
}

// Read root CheckpointSummary to find the session slot
basePath := opts.CheckpointID.Path() + "/"
rootMetadataPath := basePath + paths.MetadataFileName
entry, exists := entries[rootMetadataPath]
if !exists {
return ErrCheckpointNotFound
}

checkpointSummary, err := s.readSummaryFromBlob(entry.Hash)
if err != nil {
return fmt.Errorf("failed to read checkpoint summary: %w", err)
}
if len(checkpointSummary.Sessions) == 0 {
return ErrCheckpointNotFound
}

// Find session index matching opts.SessionID
sessionIndex := -1
for i := range len(checkpointSummary.Sessions) {
metaPath := fmt.Sprintf("%s%d/%s", basePath, i, paths.MetadataFileName)
if metaEntry, metaExists := entries[metaPath]; metaExists {
meta, metaErr := s.readMetadataFromBlob(metaEntry.Hash)
if metaErr == nil && meta.SessionID == opts.SessionID {
sessionIndex = i
break
}
}
}
if sessionIndex == -1 {
// Fall back to latest session; log so mismatches are diagnosable.
sessionIndex = len(checkpointSummary.Sessions) - 1
logging.Debug(ctx, "UpdateCommitted: session ID not found, falling back to latest",
slog.String("session_id", opts.SessionID),
slog.String("checkpoint_id", string(opts.CheckpointID)),
slog.Int("fallback_index", sessionIndex),
)
}

sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex)

// Replace transcript (full replace, not append)
// Apply redaction as safety net (caller should redact, but we ensure it here)
if len(opts.Transcript) > 0 {
transcript, err := redact.JSONLBytes(opts.Transcript)
if err != nil {
return fmt.Errorf("failed to redact transcript secrets: %w", err)
}
if err := s.replaceTranscript(transcript, opts.Agent, sessionPath, entries); err != nil {
return fmt.Errorf("failed to replace transcript: %w", err)
}
}

// Replace prompts (apply redaction as safety net)
if len(opts.Prompts) > 0 {
promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n"))
blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
if err != nil {
return fmt.Errorf("failed to create prompt blob: %w", err)
}
entries[sessionPath+paths.PromptFileName] = object.TreeEntry{
Name: sessionPath + paths.PromptFileName,
Mode: filemode.Regular,
Hash: blobHash,
}
}

// Replace context (apply redaction as safety net)
if len(opts.Context) > 0 {
contextBlob, err := CreateBlobFromContent(s.repo, redact.Bytes(opts.Context))
if err != nil {
return fmt.Errorf("failed to create context blob: %w", err)
}
entries[sessionPath+paths.ContextFileName] = object.TreeEntry{
Name: sessionPath + paths.ContextFileName,
Mode: filemode.Regular,
Hash: contextBlob,
}
}

// Build and commit
newTreeHash, err := BuildTreeFromEntries(s.repo, entries)
if err != nil {
return err
}

authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
commitMsg := fmt.Sprintf("Finalize transcript for Checkpoint: %s", opts.CheckpointID)
newCommitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail)
if err != nil {
return err
}

refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
newRef := plumbing.NewHashReference(refName, newCommitHash)
if err := s.repo.Storer.SetReference(newRef); err != nil {
return fmt.Errorf("failed to set branch reference: %w", err)
}

return nil
}

// replaceTranscript writes the full transcript content, replacing any existing transcript.
// Also removes any chunk files from a previous write and updates the content hash.
func (s *GitStore) replaceTranscript(transcript []byte, agentType agent.AgentType, sessionPath string, entries map[string]object.TreeEntry) error {
// Remove existing transcript files (base + any chunks)
transcriptBase := sessionPath + paths.TranscriptFileName
for key := range entries {
if key == transcriptBase || strings.HasPrefix(key, transcriptBase+".") {
delete(entries, key)
}
}

// Chunk the transcript (matches writeTranscript behavior)
chunks, err := agent.ChunkTranscript(transcript, agentType)
if err != nil {
return fmt.Errorf("failed to chunk transcript: %w", err)
}

// Write chunk files
for i, chunk := range chunks {
chunkPath := sessionPath + agent.ChunkFileName(paths.TranscriptFileName, i)
blobHash, err := CreateBlobFromContent(s.repo, chunk)
if err != nil {
return fmt.Errorf("failed to create transcript blob: %w", err)
}
entries[chunkPath] = object.TreeEntry{
Name: chunkPath,
Mode: filemode.Regular,
Hash: blobHash,
}
}

// Update content hash
contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(transcript))
hashBlob, err := CreateBlobFromContent(s.repo, []byte(contentHash))
if err != nil {
return fmt.Errorf("failed to create content hash blob: %w", err)
}
hashPath := sessionPath + paths.ContentHashFileName
entries[hashPath] = object.TreeEntry{
Name: hashPath,
Mode: filemode.Regular,
Hash: hashBlob,
}

return nil
}

// ensureSessionsBranch ensures the entire/checkpoints/v1 branch exists.
func (s *GitStore) ensureSessionsBranch() error {
refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
Expand All @@ -1022,7 +1196,7 @@ func (s *GitStore) ensureSessionsBranch() error {
return err
}

authorName, authorEmail := getGitAuthorFromRepo(s.repo)
authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
commitHash, err := s.createCommit(emptyTreeHash, plumbing.ZeroHash, "Initialize sessions branch", authorName, authorEmail)
if err != nil {
return err
Expand Down Expand Up @@ -1186,15 +1360,29 @@ func createRedactedBlobFromFile(repo *git.Repository, filePath, treePath string)
return hash, mode, nil
}

// getGitAuthorFromRepo retrieves the git user.name and user.email from the repository config.
func getGitAuthorFromRepo(repo *git.Repository) (name, email string) {
// GetGitAuthorFromRepo retrieves the git user.name and user.email,
// checking both the repository-local config and the global ~/.gitconfig.
func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) {
// Get repository config (includes local settings)
cfg, err := repo.Config()
if err == nil {
name = cfg.User.Name
email = cfg.User.Email
}

// If not found in local config, try global config
if name == "" || email == "" {
globalCfg, err := config.LoadConfig(config.GlobalScope)
if err == nil {
if name == "" {
name = globalCfg.User.Name
}
if email == "" {
email = globalCfg.User.Email
}
}
}

// Provide sensible defaults if git user is not configured
if name == "" {
name = "Unknown"
Expand Down
Loading