Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c0633c9
Fix attribution inflation from intermediate commits
peyton-alt Mar 31, 2026
fab7ee6
Fix attribution inflation from pre-session worktree dirt
peyton-alt Mar 31, 2026
51fe59e
Add PromptAttributionsJSON, UpdateCheckpointSummary, and CombinedAttr…
peyton-alt Mar 31, 2026
654be48
Address PR review: consistent parentTree line counting, deduplicate p…
peyton-alt Apr 1, 2026
7f17a37
Merge branch 'main' into fix/attribution-intermediate-commit-inflation
peyton-alt Apr 1, 2026
48ad357
fix: accurate per-session attribution in multi-session checkpoints
peyton-alt Apr 3, 2026
f423519
Add migrate v2 command
computermode Apr 4, 2026
8df056f
don't show multiple spaces for codex single line start message rendering
Soph Apr 5, 2026
eec5268
handle optional message concats
Soph Apr 5, 2026
8494d93
Address review; add prompt helpers
computermode Apr 6, 2026
f2a9361
Update log output for migration
computermode Apr 6, 2026
8f9b331
Update the logs
computermode Apr 6, 2026
c30039b
Merge branch 'main' into fix/attribution-intermediate-commit-inflation
peyton-alt Apr 7, 2026
5ee884b
chore: remove accidentally committed build cache
peyton-alt Apr 7, 2026
d891ac5
fix: restore .superpowers/ in .gitignore
peyton-alt Apr 7, 2026
262949c
chore: remove local-only attribution debug tests
peyton-alt Apr 7, 2026
5430c3c
Merge branch 'main' into fix/attribution-intermediate-commit-inflation
peyton-alt Apr 7, 2026
bb4afc9
test: add coverage for multi-session cross-exclusion and metadata fil…
peyton-alt Apr 7, 2026
23737d7
fix: replace naive-sum combined attribution with holistic calculation
peyton-alt Apr 7, 2026
b9fae24
Merge pull request #812 from entireio/fix/attribution-intermediate-co…
gtrrz-victor Apr 7, 2026
9a070c1
Merge pull request #857 from entireio/soph/codex-warning-fix
Soph Apr 7, 2026
e72a29e
linter
computermode Apr 7, 2026
1e97bad
Merge branch 'main' of https://github.com/entireio/cli into add-migra…
computermode Apr 7, 2026
6f282ba
Switch from if to switch statement
computermode Apr 7, 2026
f0ebe4d
Remove migrate command discoverability for now
computermode Apr 7, 2026
d7e367f
Move 'origin' to const, add helpers
computermode Apr 7, 2026
67bd8f7
Fix task handling
computermode Apr 7, 2026
2663640
linter
computermode Apr 7, 2026
cb31d50
Check for any missing components when rerunning a backfill
computermode Apr 7, 2026
93edd79
Linter
computermode Apr 7, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,5 @@ docs/plans
docs/reviews
docs/superpowers
tmp/
.tmp/
.superpowers/
28 changes: 20 additions & 8 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package checkpoint

import (
"context"
"encoding/json"
"errors"
"time"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -411,6 +418,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.
Expand Down Expand Up @@ -452,14 +463,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
Expand Down
139 changes: 129 additions & 10 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom

// Write prompts
if len(opts.Prompts) > 0 {
promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n"))
promptContent := redact.String(JoinPrompts(opts.Prompts))
blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
if err != nil {
return filePaths, err
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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, "", " ")
Expand All @@ -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 {
Expand Down Expand Up @@ -773,6 +854,44 @@ func (s *GitStore) ReadCommitted(ctx context.Context, checkpointID id.Checkpoint
return &summary, nil
}

// ReadSessionMetadata reads only the metadata.json for a specific session within a checkpoint.
// This is a lightweight read that avoids fetching transcript/prompt blobs.
// sessionIndex is 0-based.
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()
sessionPath := fmt.Sprintf("%s/%d", checkpointPath, sessionIndex)
sessionTree, err := ft.Tree(sessionPath)
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("metadata.json not found for session %d: %w", sessionIndex, err)
}

content, err := metadataFile.Contents()
if err != nil {
return nil, fmt.Errorf("failed to read session metadata: %w", err)
}

var metadata CommittedMetadata
if err := json.Unmarshal([]byte(content), &metadata); err != nil {
return nil, fmt.Errorf("failed to parse session metadata: %w", err)
}

return &metadata, nil
}

// ReadSessionContent reads the actual content for a specific session within a checkpoint.
// sessionIndex is 0-based (0 for first session, 1 for second, etc.).
// Returns the session's metadata, transcript, prompts, and context.
Expand Down Expand Up @@ -1162,7 +1281,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti

// Replace prompts (apply redaction as safety net)
if len(opts.Prompts) > 0 {
promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n"))
promptContent := redact.String(JoinPrompts(opts.Prompts))
blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
if err != nil {
return fmt.Errorf("failed to create prompt blob: %w", err)
Expand Down
25 changes: 25 additions & 0 deletions cmd/entire/cli/checkpoint/prompts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package checkpoint

import "strings"

// PromptSeparator is the canonical separator used in prompt.txt when multiple
// prompts are stored in a single file.
const PromptSeparator = "\n\n---\n\n"

// JoinPrompts serializes prompts to prompt.txt format.
func JoinPrompts(prompts []string) string {
return strings.Join(prompts, PromptSeparator)
}

// SplitPromptContent deserializes prompt.txt content into individual prompts.
func SplitPromptContent(content string) []string {
if content == "" {
return nil
}

prompts := strings.Split(content, PromptSeparator)
for len(prompts) > 0 && prompts[len(prompts)-1] == "" {
prompts = prompts[:len(prompts)-1]
}
return prompts
}
29 changes: 29 additions & 0 deletions cmd/entire/cli/checkpoint/prompts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package checkpoint

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestJoinAndSplitPrompts_RoundTrip(t *testing.T) {
t.Parallel()

original := []string{
"first line\nwith newline",
"second prompt",
}

joined := JoinPrompts(original)
split := SplitPromptContent(joined)

require.Len(t, split, 2)
assert.Equal(t, original, split)
}

func TestSplitPromptContent_EmptyContent(t *testing.T) {
t.Parallel()

assert.Nil(t, SplitPromptContent(""))
}
5 changes: 3 additions & 2 deletions cmd/entire/cli/checkpoint/v2_committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommitt
sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex)

if len(opts.Prompts) > 0 {
promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n"))
promptContent := redact.String(JoinPrompts(opts.Prompts))
blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
if err != nil {
return 0, fmt.Errorf("failed to create prompt blob: %w", err)
Expand Down Expand Up @@ -334,7 +334,7 @@ func (s *V2GitStore) writeMainSessionToSubdirectory(opts WriteCommittedOptions,

// Write prompts
if len(opts.Prompts) > 0 {
promptContent := redact.String(strings.Join(opts.Prompts, "\n\n---\n\n"))
promptContent := redact.String(JoinPrompts(opts.Prompts))
blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
if err != nil {
return filePaths, err
Expand Down Expand Up @@ -385,6 +385,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,
}
Expand Down
25 changes: 22 additions & 3 deletions cmd/entire/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"time"

"github.com/entireio/cli/cmd/entire/cli/agent"
"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"
Expand Down Expand Up @@ -79,16 +80,20 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age

// Build informational message — warn early if repo has no commits yet,
// since checkpoints require at least one commit to work.
message := "\n\nPowered by Entire:\n This conversation will be linked to your next commit."
message := sessionStartMessage(ag.Name(), false)
if repo, err := strategy.OpenRepository(ctx); err == nil && strategy.IsEmptyRepository(repo) {
message = "\n\nPowered by Entire:\n No commits yet — checkpoints will activate after your first commit."
message = sessionStartMessage(ag.Name(), true)
}

// Check for concurrent sessions and append count if any
_, countSessionsSpan := perf.Start(ctx, "count_active_sessions")
strat := GetStrategy(ctx)
if count, err := strat.CountOtherActiveSessionsWithCheckpoints(ctx, event.SessionID); err == nil && count > 0 {
message += fmt.Sprintf("\n %d other active conversation(s) in this workspace will also be included.\n Use 'entire status' for more information.", count)
if ag.Name() == agent.AgentNameCodex {
message += fmt.Sprintf(" %d other active conversation(s) in this workspace will also be included. Use 'entire status' for more information.", count)
} else {
message += fmt.Sprintf("\n %d other active conversation(s) in this workspace will also be included.\n Use 'entire status' for more information.", count)
}
}
countSessionsSpan.End()

Expand Down Expand Up @@ -135,6 +140,20 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age
return nil
}

func sessionStartMessage(agentName types.AgentName, emptyRepo bool) string {
if agentName == agent.AgentNameCodex {
if emptyRepo {
return "Powered by Entire: No commits yet — checkpoints will activate after your first commit."
}
return "Powered by Entire: This conversation will be linked to your next commit."
}

if emptyRepo {
return "\n\nPowered by Entire:\n No commits yet — checkpoints will activate after your first commit."
}
return "\n\nPowered by Entire:\n This conversation will be linked to your next commit."
}

// handleLifecycleModelUpdate persists the model name for the current session.
//
// If the session state file already exists (e.g., Gemini's BeforeModel fires
Expand Down
Loading
Loading