From 9041b721661d4b6de8b99b1c6301812fbe86538b Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 30 Mar 2026 19:22:06 -0700 Subject: [PATCH 1/3] Fix multi-session attribution summary loss --- cmd/entire/cli/checkpoint/checkpoint.go | 28 ++++-- cmd/entire/cli/checkpoint/committed.go | 97 +++++++++++++++++-- cmd/entire/cli/checkpoint/v2_committed.go | 1 + .../strategy/manual_commit_condensation.go | 14 +++ .../cli/strategy/manual_commit_hooks.go | 72 ++++++++++++++ 5 files changed, 196 insertions(+), 16 deletions(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 40133fbcf..5932e7bdd 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -8,6 +8,7 @@ package checkpoint import ( "context" + "encoding/json" "errors" "time" @@ -282,6 +283,12 @@ type WriteCommittedOptions struct { // comparing checkpoint tree (agent work) to committed tree (may include human edits) InitialAttribution *InitialAttribution + // PromptAttributionsJSON is the raw PromptAttributions data, JSON-encoded. + // Persisted for diagnostic purposes — shows exactly which prompt recorded + // which "user" lines, enabling root cause analysis of attribution bugs. + // Uses json.RawMessage to avoid importing session package. + PromptAttributionsJSON json.RawMessage + // Summary is an optional AI-generated summary for this checkpoint. // This field may be nil when: // - summarization is disabled in settings @@ -402,6 +409,10 @@ type CommittedMetadata struct { // InitialAttribution is line-level attribution calculated at commit time InitialAttribution *InitialAttribution `json:"initial_attribution,omitempty"` + + // PromptAttributions is the raw per-prompt attribution data used to compute InitialAttribution. + // Diagnostic field — shows which prompt recorded which "user" lines. + PromptAttributions json.RawMessage `json:"prompt_attributions,omitempty"` } // GetTranscriptStart returns the transcript line offset at which this checkpoint's data begins. @@ -443,14 +454,15 @@ type SessionFilePaths struct { // //nolint:revive // Named CheckpointSummary to avoid conflict with existing Summary struct type CheckpointSummary struct { - CLIVersion string `json:"cli_version,omitempty"` - CheckpointID id.CheckpointID `json:"checkpoint_id"` - Strategy string `json:"strategy"` - Branch string `json:"branch,omitempty"` - CheckpointsCount int `json:"checkpoints_count"` - FilesTouched []string `json:"files_touched"` - Sessions []SessionFilePaths `json:"sessions"` - TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"` + CLIVersion string `json:"cli_version,omitempty"` + CheckpointID id.CheckpointID `json:"checkpoint_id"` + Strategy string `json:"strategy"` + Branch string `json:"branch,omitempty"` + CheckpointsCount int `json:"checkpoints_count"` + FilesTouched []string `json:"files_touched"` + Sessions []SessionFilePaths `json:"sessions"` + TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"` + CombinedAttribution *InitialAttribution `json:"combined_attribution,omitempty"` } // SessionMetrics contains hook-provided session metrics from agents that report diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 47a75beb8..0f70d47fa 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -383,6 +383,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom TokenUsage: opts.TokenUsage, SessionMetrics: opts.SessionMetrics, InitialAttribution: opts.InitialAttribution, + PromptAttributions: opts.PromptAttributionsJSON, Summary: redactSummary(opts.Summary), CLIVersion: versioninfo.Version, } @@ -414,15 +415,25 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s return fmt.Errorf("failed to aggregate session stats: %w", err) } + var combinedAttribution *InitialAttribution + rootMetadataPath := basePath + paths.MetadataFileName + if entry, exists := entries[rootMetadataPath]; exists { + existingSummary, readErr := s.readSummaryFromBlob(entry.Hash) + if readErr == nil { + combinedAttribution = existingSummary.CombinedAttribution + } + } + summary := CheckpointSummary{ - CheckpointID: opts.CheckpointID, - CLIVersion: versioninfo.Version, - Strategy: opts.Strategy, - Branch: opts.Branch, - CheckpointsCount: checkpointsCount, - FilesTouched: filesTouched, - Sessions: sessions, - TokenUsage: tokenUsage, + CheckpointID: opts.CheckpointID, + CLIVersion: versioninfo.Version, + Strategy: opts.Strategy, + Branch: opts.Branch, + CheckpointsCount: checkpointsCount, + FilesTouched: filesTouched, + Sessions: sessions, + TokenUsage: tokenUsage, + CombinedAttribution: combinedAttribution, } metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ") @@ -441,6 +452,76 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s return nil } +// UpdateCheckpointSummary updates root-level checkpoint metadata fields that depend +// on the full set of sessions already written to the checkpoint. +func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, checkpointID id.CheckpointID, combinedAttribution *InitialAttribution) error { + if err := ctx.Err(); err != nil { + return err //nolint:wrapcheck // Propagating context cancellation + } + + if err := s.ensureSessionsBranch(); err != nil { + return fmt.Errorf("failed to ensure sessions branch: %w", err) + } + + parentHash, rootTreeHash, err := s.getSessionsBranchRef() + if err != nil { + return err + } + + basePath := checkpointID.Path() + "/" + checkpointPath := checkpointID.Path() + entries, err := s.flattenCheckpointEntries(rootTreeHash, checkpointPath) + if err != nil { + return err + } + + rootMetadataPath := basePath + paths.MetadataFileName + entry, exists := entries[rootMetadataPath] + if !exists { + return ErrCheckpointNotFound + } + + summary, err := s.readSummaryFromBlob(entry.Hash) + if err != nil { + return fmt.Errorf("failed to read checkpoint summary: %w", err) + } + summary.CombinedAttribution = combinedAttribution + + metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal checkpoint summary: %w", err) + } + metadataHash, err := CreateBlobFromContent(s.repo, metadataJSON) + if err != nil { + return fmt.Errorf("failed to create checkpoint summary blob: %w", err) + } + entries[rootMetadataPath] = object.TreeEntry{ + Name: rootMetadataPath, + Mode: filemode.Regular, + Hash: metadataHash, + } + + newTreeHash, err := s.spliceCheckpointSubtree(rootTreeHash, checkpointID, basePath, entries) + if err != nil { + return err + } + + authorName, authorEmail := GetGitAuthorFromRepo(s.repo) + commitMsg := fmt.Sprintf("Update checkpoint summary for %s", checkpointID) + newCommitHash, err := s.createCommit(newTreeHash, parentHash, 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 +} + // findSessionIndex returns the index of an existing session with the given ID, // or the next available index if not found. This prevents duplicate session entries. func (s *GitStore) findSessionIndex(ctx context.Context, basePath string, existingSummary *CheckpointSummary, entries map[string]object.TreeEntry, sessionID string) int { diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index d5a7a4f34..f9f19bb3f 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -329,6 +329,7 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions, TokenUsage: opts.TokenUsage, SessionMetrics: opts.SessionMetrics, InitialAttribution: opts.InitialAttribution, + PromptAttributions: opts.PromptAttributionsJSON, Summary: redactSummary(opts.Summary), CLIVersion: versioninfo.Version, } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 2e80e270e..7603744c7 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -264,6 +264,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re TokenUsage: sessionData.TokenUsage, SessionMetrics: buildSessionMetrics(state), InitialAttribution: attribution, + PromptAttributionsJSON: marshalPromptAttributions(state.PromptAttributions), Summary: summary, } @@ -287,6 +288,19 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re // buildSessionMetrics creates a SessionMetrics from session state if any metrics are available. // Returns nil if no hook-provided metrics exist (e.g., for agents that don't report them). +// marshalPromptAttributions encodes PromptAttributions to JSON for diagnostic persistence. +// Returns nil if there are no attributions to persist. +func marshalPromptAttributions(pas []PromptAttribution) json.RawMessage { + if len(pas) == 0 { + return nil + } + data, err := json.Marshal(pas) + if err != nil { + return nil + } + return data +} + func buildSessionMetrics(state *SessionState) *cpkg.SessionMetrics { if state.SessionDurationMs == 0 && state.SessionTurnCount == 0 && state.ContextTokens == 0 && state.ContextWindowSize == 0 { return nil diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index c5e302def..f13b28367 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -863,6 +863,12 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint: } processSessionsLoop.End() + if err := s.updateCombinedAttributionForCheckpoint(ctx, repo, checkpointID); err != nil { + logging.Warn(logCtx, "failed to update combined checkpoint attribution", + slog.String("checkpoint_id", checkpointID.String()), + slog.String("error", err.Error())) + } + // Clean up shadow branches — only delete when ALL sessions on the branch are non-active // or were condensed during this PostCommit. _, cleanupBranchesSpan := perf.Start(ctx, "cleanup_shadow_branches") @@ -889,6 +895,72 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint: return nil } +func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID) error { + store := checkpoint.NewGitStore(repo) + + summary, err := store.ReadCommitted(ctx, checkpointID) + if err != nil { + return err + } + if summary == nil || len(summary.Sessions) <= 1 { + return nil + } + + combined := aggregateCheckpointAttribution() + for i := range len(summary.Sessions) { + content, readErr := store.ReadSessionContent(ctx, checkpointID, i) + if readErr != nil || content == nil || content.Metadata.InitialAttribution == nil { + continue + } + combined.add(content.Metadata.InitialAttribution) + } + + if !combined.hasData() { + return nil + } + + return store.UpdateCheckpointSummary(ctx, checkpointID, combined.snapshot()) +} + +type checkpointAttributionAggregate struct { + agentLines int + humanAdded int + humanModified int + humanRemoved int + totalCommitted int +} + +func aggregateCheckpointAttribution() *checkpointAttributionAggregate { + return &checkpointAttributionAggregate{} +} + +func (a *checkpointAttributionAggregate) add(attr *checkpoint.InitialAttribution) { + a.agentLines += attr.AgentLines + a.humanAdded += attr.HumanAdded + a.humanModified += attr.HumanModified + a.humanRemoved += attr.HumanRemoved + a.totalCommitted += attr.TotalCommitted +} + +func (a *checkpointAttributionAggregate) hasData() bool { + return a.agentLines != 0 || a.humanAdded != 0 || a.humanModified != 0 || a.humanRemoved != 0 || a.totalCommitted != 0 +} + +func (a *checkpointAttributionAggregate) snapshot() *checkpoint.InitialAttribution { + attr := &checkpoint.InitialAttribution{ + CalculatedAt: time.Now().UTC(), + AgentLines: a.agentLines, + HumanAdded: a.humanAdded, + HumanModified: a.humanModified, + HumanRemoved: a.humanRemoved, + TotalCommitted: a.totalCommitted, + } + if a.totalCommitted > 0 { + attr.AgentPercentage = float64(a.agentLines) / float64(a.totalCommitted) * 100 + } + return attr +} + // postCommitProcessSession handles a single session within the PostCommit loop. // Pre-resolved git objects (headTree, parentTree) are shared across all sessions; // per-session shadow ref/tree are resolved once here and threaded through sub-calls. From bb98784236398cb0d80958ef89bbce10a39799e3 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 30 Mar 2026 20:39:56 -0700 Subject: [PATCH 2/3] Fix wrapcheck lint errors in updateCombinedAttributionForCheckpoint Wrap errors returned from checkpoint.GitStore methods (ReadCommitted, UpdateCheckpointSummary) with fmt.Errorf to satisfy the wrapcheck linter. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/entire/cli/strategy/manual_commit_hooks.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index f13b28367..4762ca10d 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -900,7 +900,7 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(ctx contex summary, err := store.ReadCommitted(ctx, checkpointID) if err != nil { - return err + return fmt.Errorf("reading checkpoint summary: %w", err) } if summary == nil || len(summary.Sessions) <= 1 { return nil @@ -919,7 +919,11 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(ctx contex return nil } - return store.UpdateCheckpointSummary(ctx, checkpointID, combined.snapshot()) + if err := store.UpdateCheckpointSummary(ctx, checkpointID, combined.snapshot()); err != nil { + return fmt.Errorf("updating combined attribution: %w", err) + } + + return nil } type checkpointAttributionAggregate struct { From 45a2d9346a9c55d7a1233b30dc5fd1bbe7734928 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 31 Mar 2026 19:13:28 -0700 Subject: [PATCH 3/3] Address PR review: doc comments, marshal logging, lightweight reads, multi-session test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes all 4 issues from Copilot and Cursor Bugbot review: 1. Fix misplaced doc comment — marshalPromptAttributions and buildSessionMetrics now have their own correctly positioned doc comments 2. Log warning on marshal failure in marshalPromptAttributions instead of silently returning nil 3. Add ReadSessionMetadata to GitStore — lightweight method that reads only session metadata.json (no transcript/prompt blobs). Used by updateCombinedAttributionForCheckpoint to avoid expensive reads 4. Add TestUpdateCombinedAttribution_MultiSession verifying combined_attribution is computed and persisted across 2 sessions Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/entire/cli/checkpoint/committed.go | 43 ++++++++ .../strategy/manual_commit_condensation.go | 8 +- .../cli/strategy/manual_commit_hooks.go | 6 +- cmd/entire/cli/strategy/manual_commit_test.go | 104 ++++++++++++++++++ 4 files changed, 155 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 0f70d47fa..3156898bb 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -905,6 +905,49 @@ func (s *GitStore) ReadSessionContent(ctx context.Context, checkpointID id.Check return result, nil } +// ReadSessionMetadata reads only the metadata.json for a session (no transcript or prompts). +// This is much cheaper than ReadSessionContent for cases that only need metadata fields +// like InitialAttribution. +func (s *GitStore) ReadSessionMetadata(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*CommittedMetadata, error) { + if err := ctx.Err(); err != nil { + return nil, err //nolint:wrapcheck // Propagating context cancellation + } + + ft, err := s.getFetchingTree(ctx) + if err != nil { + return nil, ErrCheckpointNotFound + } + + checkpointPath := checkpointID.Path() + checkpointTree, err := ft.Tree(checkpointPath) + if err != nil { + return nil, ErrCheckpointNotFound + } + + sessionDir := strconv.Itoa(sessionIndex) + sessionTree, err := checkpointTree.Tree(sessionDir) + if err != nil { + return nil, fmt.Errorf("session %d not found: %w", sessionIndex, err) + } + + metadataFile, err := sessionTree.File(paths.MetadataFileName) + if err != nil { + return nil, fmt.Errorf("session %d metadata not found: %w", sessionIndex, err) + } + + content, err := metadataFile.Contents() + if err != nil { + return nil, fmt.Errorf("failed to read session %d metadata: %w", sessionIndex, err) + } + + var metadata CommittedMetadata + if err := json.Unmarshal([]byte(content), &metadata); err != nil { + return nil, fmt.Errorf("failed to parse session %d metadata: %w", sessionIndex, err) + } + + return &metadata, nil +} + // ReadLatestSessionContent is a convenience method that reads the latest session's content. // This is equivalent to ReadSessionContent(ctx, checkpointID, len(summary.Sessions)-1). func (s *GitStore) ReadLatestSessionContent(ctx context.Context, checkpointID id.CheckpointID) (*SessionContent, error) { diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 7603744c7..95e1a94cb 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -286,21 +286,23 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re }, nil } -// buildSessionMetrics creates a SessionMetrics from session state if any metrics are available. -// Returns nil if no hook-provided metrics exist (e.g., for agents that don't report them). // marshalPromptAttributions encodes PromptAttributions to JSON for diagnostic persistence. -// Returns nil if there are no attributions to persist. +// Returns nil if there are no attributions to persist or if marshaling fails (logged as warning). func marshalPromptAttributions(pas []PromptAttribution) json.RawMessage { if len(pas) == 0 { return nil } data, err := json.Marshal(pas) if err != nil { + slog.Warn("failed to marshal prompt attributions for diagnostic persistence", + slog.String("error", err.Error())) return nil } return data } +// buildSessionMetrics creates a SessionMetrics from session state if any metrics are available. +// Returns nil if no hook-provided metrics exist (e.g., for agents that don't report them). func buildSessionMetrics(state *SessionState) *cpkg.SessionMetrics { if state.SessionDurationMs == 0 && state.SessionTurnCount == 0 && state.ContextTokens == 0 && state.ContextWindowSize == 0 { return nil diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 4762ca10d..de825131f 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -908,11 +908,11 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(ctx contex combined := aggregateCheckpointAttribution() for i := range len(summary.Sessions) { - content, readErr := store.ReadSessionContent(ctx, checkpointID, i) - if readErr != nil || content == nil || content.Metadata.InitialAttribution == nil { + metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i) + if readErr != nil || metadata == nil || metadata.InitialAttribution == nil { continue } - combined.add(content.Metadata.InitialAttribution) + combined.add(metadata.InitialAttribution) } if !combined.hasData() { diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 5f4102b16..71d6934a7 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -4051,3 +4051,107 @@ func TestCondenseSession_V2Disabled_NoV2Refs(t *testing.T) { _, err = repo.Reference(plumbing.ReferenceName(paths.V2FullCurrentRefName), true) require.Error(t, err, "v2 /full/current ref should not exist when v2 is disabled") } + +// TestUpdateCombinedAttribution_MultiSession verifies that combined_attribution +// is correctly computed and persisted across multiple session condensations, +// and survives subsequent checkpoint writes. +func TestUpdateCombinedAttribution_MultiSession(t *testing.T) { + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + require.NoError(t, err) + + wt, err := repo.Worktree() + require.NoError(t, err) + + // Create initial commit + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o644)) + _, err = wt.Add("main.go") + require.NoError(t, err) + initialHash, err := wt.Commit("init", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "t@t.com", When: time.Now()}, + }) + require.NoError(t, err) + + // Agent writes file1 (session 1) + require.NoError(t, os.WriteFile(filepath.Join(dir, "file1.go"), []byte("func a() {}\nfunc b() {}\nfunc c() {}\n"), 0o644)) + _, err = wt.Add("file1.go") + require.NoError(t, err) + _, err = wt.Commit("Add file1", &git.CommitOptions{ + Author: &object.Signature{Name: "Agent", Email: "a@t.com", When: time.Now()}, + }) + require.NoError(t, err) + + t.Chdir(dir) + + // Create transcript for session 1 + transcriptDir := filepath.Join(dir, ".claude", "projects", "test") + require.NoError(t, os.MkdirAll(transcriptDir, 0o755)) + transcriptFile1 := filepath.Join(transcriptDir, "session1.jsonl") + require.NoError(t, os.WriteFile(transcriptFile1, []byte(`{"type":"human","message":{"content":"create file1"}} +{"type":"assistant","message":{"content":"Done"}} +`), 0o644)) + + checkpointID := id.MustCheckpointID("aabbccddeeff") + s := &ManualCommitStrategy{} + + // Condense session 1 + state1 := &SessionState{ + SessionID: "session-1", + BaseCommit: initialHash.String(), + AttributionBaseCommit: initialHash.String(), + FilesTouched: []string{"file1.go"}, + TranscriptPath: transcriptFile1, + AgentType: "Claude Code", + } + result1, err := s.CondenseSession(context.Background(), repo, checkpointID, state1, map[string]struct{}{"file1.go": {}}) + require.NoError(t, err) + require.Equal(t, checkpointID, result1.CheckpointID) + + // Condense session 2 (different agent, same checkpoint) + transcriptFile2 := filepath.Join(transcriptDir, "session2.jsonl") + require.NoError(t, os.WriteFile(transcriptFile2, []byte(`{"type":"human","message":{"content":"create file2"}} +{"type":"assistant","message":{"content":"Done"}} +`), 0o644)) + + // Agent writes file2 (session 2) + require.NoError(t, os.WriteFile(filepath.Join(dir, "file2.go"), []byte("func x() {}\nfunc y() {}\n"), 0o644)) + _, err = wt.Add("file2.go") + require.NoError(t, err) + _, err = wt.Commit("Add file2", &git.CommitOptions{ + Author: &object.Signature{Name: "Agent2", Email: "a2@t.com", When: time.Now()}, + }) + require.NoError(t, err) + + state2 := &SessionState{ + SessionID: "session-2", + BaseCommit: initialHash.String(), + AttributionBaseCommit: initialHash.String(), + FilesTouched: []string{"file2.go"}, + TranscriptPath: transcriptFile2, + AgentType: "Claude Code", + } + result2, err := s.CondenseSession(context.Background(), repo, checkpointID, state2, map[string]struct{}{"file2.go": {}}) + require.NoError(t, err) + require.Equal(t, checkpointID, result2.CheckpointID) + + // Now call updateCombinedAttributionForCheckpoint + err = s.updateCombinedAttributionForCheckpoint(context.Background(), repo, checkpointID) + require.NoError(t, err) + + // Read root metadata and verify combined_attribution exists + store := checkpoint.NewGitStore(repo) + summary, err := store.ReadCommitted(context.Background(), checkpointID) + require.NoError(t, err) + require.NotNil(t, summary) + require.Len(t, summary.Sessions, 2, "should have 2 sessions") + require.NotNil(t, summary.CombinedAttribution, "combined_attribution should be set") + + // Both sessions had agent-only work + combined := summary.CombinedAttribution + t.Logf("Combined: agent=%d, human_added=%d, total=%d, pct=%.1f%%", + combined.AgentLines, combined.HumanAdded, combined.TotalCommitted, combined.AgentPercentage) + + require.Positive(t, combined.AgentLines, "combined should have agent lines") + require.Positive(t, combined.TotalCommitted, "combined should have total") + require.Greater(t, combined.AgentPercentage, 0.0, "combined should have percentage") +}